fix: searchable client combobox in Nova instalação

Replaces the native list with a combobox (name/code/CNPJ search, accent- and
case-insensitive, keyboard + mouse, custom dropdown, hidden client_id = the
Gerenciador's real CLIENTE.ID). The backend resolves client_id against the
active GTI_GERENCIADOR mirror and rejects free text/unknown ids. Adds
Tenant.external_cnpj (migration 0025) mirrored via the internal endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 08:12:52 -03:00
co-authored by Claude Sonnet 5
parent 3ccef36c50
commit 9f9e06e9e9
9 changed files with 156 additions and 40 deletions
@@ -0,0 +1,20 @@
"""Tenant.external_cnpj (digits) mirrored from GTI Gerenciador for the client selector search.
Revision ID: 0025_tenant_external_cnpj
Revises: 0024_tenant_gerenciador_mirror
"""
from alembic import op
import sqlalchemy as sa
revision = "0025_tenant_external_cnpj"
down_revision = "0024_tenant_gerenciador_mirror"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("tenants", sa.Column("external_cnpj", sa.String(20), nullable=True))
def downgrade():
op.drop_column("tenants", "external_cnpj")
+4 -1
View File
@@ -74,7 +74,7 @@ def sync_from_gerenciador(session, clients: list[dict]) -> SyncResult:
if row is None:
name = wanted_name if wanted_name not in taken_names else f"{wanted_name} ({code})"
row = Tenant(name=name, source=SOURCE, external_client_id=external_id, external_code=code, active=active, synced_at=now)
row = Tenant(name=name, source=SOURCE, external_client_id=external_id, external_code=code, external_cnpj=client.get("cnpj"), active=active, synced_at=now)
session.add(row)
taken_names.add(name)
created += 1
@@ -89,6 +89,9 @@ def sync_from_gerenciador(session, clients: list[dict]) -> SyncResult:
if row.external_code != code:
row.external_code = code
changed = True
if row.external_cnpj != client.get("cnpj"):
row.external_cnpj = client.get("cnpj")
changed = True
if row.active != active:
row.active = active
changed = True
+1
View File
@@ -229,6 +229,7 @@ class Tenant(Base):
source: Mapped[str] = mapped_column(String(16), default="LOCAL", server_default="LOCAL") # LOCAL | GTI_GERENCIADOR
external_client_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
external_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
external_cnpj: Mapped[str | None] = mapped_column(String(20), nullable=True) # digits only, for the client selector search
active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+11 -6
View File
@@ -208,6 +208,11 @@ def router(session_factory):
if search: query = query.where(Tenant.name.ilike(f"%{search}%"))
return session.scalars(query.order_by(Tenant.name)).all()
def _clients_json(tenants):
import json
# "<" escaped so the payload can never close the <script> tag it is embedded in.
return json.dumps([{"id": t.external_client_id, "name": t.name, "code": t.external_code or "", "cnpj": t.external_cnpj or ""} for t in tenants], ensure_ascii=False).replace("<", "\u003c")
@routes.get("/agents/new", response_class=HTMLResponse)
def new_installation_form(request: Request, search: str | None = None):
session, auth_data, redirect = page_auth(request)
@@ -215,7 +220,7 @@ def router(session_factory):
try:
tenants = _active_gerenciador_tenants(session, search)
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None, "search": search or ""})
return render(request, "agent_new_installation.html", {"tenants": tenants, "clients_json": _clients_json(tenants), "releases": releases, "auth": auth_data, "error": None, "search": search or ""})
finally: session.close()
@routes.post("/agents/new")
@@ -225,24 +230,24 @@ def router(session_factory):
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
tenant_id = values.get("tenant_id")
client_id = values.get("client_id") # Gerenciador's real CLIENTE.ID, never the text shown in the combobox
display_name = (values.get("display_name") or "").strip()[:160]
expected_hostname = (values.get("expected_hostname") or "").strip()[:255] or None
release_id = values.get("release_id")
errors = []
tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None
tenant = session.scalar(select(Tenant).where(Tenant.source == client_sync.SOURCE, Tenant.external_client_id == int(client_id))) if client_id and client_id.isdigit() else None
release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None
# Server-side, not just a hidden option in the form: a LOCAL or
# inactive tenant can never receive a new installation, matching
# GTI Gerenciador's own active/inactive rule.
if tenant and (tenant.source != client_sync.SOURCE or not tenant.active): tenant = None
if not tenant: errors.append("Selecione um cliente ativo válido (cadastrado no GTI Gerenciador).")
if tenant and not tenant.active: tenant = None
if not tenant: errors.append("Selecione um cliente da lista.")
if not display_name: errors.append("Informe um nome para a instalação.")
if not release or release.status != "PUBLISHED": errors.append("Selecione uma versão publicada do Agent.")
if errors:
tenants = _active_gerenciador_tenants(session)
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors), "search": ""}, 400)
return render(request, "agent_new_installation.html", {"tenants": tenants, "clients_json": _clients_json(tenants), "releases": releases, "auth": auth_data, "error": " ".join(errors), "search": ""}, 400)
enrollment_uuid = str(uuid.uuid4())
enr = AgentEnrollment(enrollment_uuid=enrollment_uuid, tenant_id=tenant.id, display_name=display_name, expected_hostname=expected_hostname, release_id=release.id, status="PENDING", created_by=admin.id)
session.add(enr)
+74 -11
View File
@@ -11,12 +11,16 @@
{% else %}
<form method="post" action="/agents/new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Cliente
<input type="text" id="filtro-cliente" placeholder="Buscar cliente..." autocomplete="off" oninput="filtrarClientes(this.value)">
<select name="tenant_id" id="select-cliente" size="8" required>
{% for tenant in tenants %}<option value="{{ tenant.id }}">{{ tenant.name }}</option>{% endfor %}
</select>
</label>
<div class="combo-field">
<label for="cliente-busca">Cliente</label>
<div class="combo" id="combo-cliente">
<input type="text" id="cliente-busca" placeholder="Buscar por nome, código ou CNPJ..." autocomplete="off" role="combobox" aria-expanded="false" aria-controls="cliente-lista" aria-autocomplete="list">
<input type="hidden" name="client_id" id="cliente-id" value="">
<ul id="cliente-lista" class="combo-lista" role="listbox" hidden></ul>
</div>
<p class="error combo-erro" id="cliente-erro" role="alert" hidden>Selecione um cliente da lista.</p>
</div>
<script type="application/json" id="clientes-json">{{ clients_json|safe }}</script>
<label>Nome da instalação
<input name="display_name" placeholder="ex. Servidor Principal" maxlength="160" required>
</label>
@@ -30,13 +34,72 @@
</label>
<p><button type="submit">Criar instalação</button></p>
</form>
<style>
.combo { position: relative; }
.combo-lista { position: absolute; z-index: 20; left: 0; right: 0; top: 100%; margin: 4px 0 0; padding: 4px; list-style: none; max-height: 280px; overflow-y: auto; background: var(--cor-superficie); border: 1px solid var(--cor-borda); border-radius: 8px; box-shadow: var(--sombra-card); }
.combo-lista li { padding: 8px 10px; border-radius: 6px; cursor: pointer; display: flex; flex-direction: column; gap: 2px; }
.combo-lista li small { color: var(--cor-texto-secundario); }
.combo-lista li:hover, .combo-lista li.ativo { background: var(--cor-superficie-alt); }
.combo-lista li.selecionado { background: var(--cor-acento-suave); color: var(--cor-acento-texto); font-weight: 600; }
.combo-lista li.vazio { cursor: default; color: var(--cor-texto-secundario); }
.combo.invalido input[type=text] { border-color: var(--cor-perigo); }
.combo-field { margin-bottom: 12px; }
</style>
<script>
function filtrarClientes(termo) {
var alvo = termo.trim().toLowerCase();
document.querySelectorAll("#select-cliente option").forEach(function (opcao) {
opcao.hidden = alvo.length > 0 && opcao.textContent.toLowerCase().indexOf(alvo) === -1;
});
(function () {
var clientes = JSON.parse(document.getElementById("clientes-json").textContent);
var box = document.getElementById("cliente-busca"), hid = document.getElementById("cliente-id");
var lista = document.getElementById("cliente-lista"), erro = document.getElementById("cliente-erro");
var combo = document.getElementById("combo-cliente"), form = box.form;
var visiveis = [], ativo = -1;
function norm(t) { return (t || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); }
function cnpjFmt(d) {
if (!d) return "";
if (d.length === 14) return d.replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/, "$1.$2.$3/$4-$5");
if (d.length === 11) return d.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, "$1.$2.$3-$4");
return d;
}
function abrir(v) { lista.hidden = !v; box.setAttribute("aria-expanded", v ? "true" : "false"); }
function render() {
var q = norm(box.value), qd = q.replace(/\D/g, "");
visiveis = clientes.filter(function (c) {
if (!q) return true;
return norm(c.name).indexOf(q) !== -1 || norm(c.code).indexOf(q) !== -1 || (qd && (c.cnpj || "").indexOf(qd) !== -1);
}).slice(0, 100);
lista.innerHTML = "";
if (!visiveis.length) {
var vazio = document.createElement("li"); vazio.className = "vazio"; vazio.textContent = "Nenhum cliente ativo encontrado."; lista.appendChild(vazio);
}
visiveis.forEach(function (c, i) {
var li = document.createElement("li"); li.setAttribute("role", "option"); li.dataset.i = i;
if (String(c.id) === hid.value) li.classList.add("selecionado");
var nome = document.createElement("span"); nome.textContent = c.name; li.appendChild(nome);
if (c.cnpj) { var sm = document.createElement("small"); sm.textContent = cnpjFmt(c.cnpj); li.appendChild(sm); }
li.addEventListener("mousedown", function (e) { e.preventDefault(); escolher(c); });
lista.appendChild(li);
});
ativo = -1; abrir(true);
}
function destacar(n) {
var itens = lista.querySelectorAll("li[role=option]"); if (!itens.length) return;
ativo = (n + itens.length) % itens.length;
itens.forEach(function (li, i) { li.classList.toggle("ativo", i === ativo); });
itens[ativo].scrollIntoView({ block: "nearest" });
}
function escolher(c) { box.value = c.name; hid.value = c.id; erro.hidden = true; combo.classList.remove("invalido"); abrir(false); }
box.addEventListener("input", function () { hid.value = ""; render(); });
box.addEventListener("focus", render);
box.addEventListener("blur", function () { setTimeout(function () { abrir(false); }, 120); });
box.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); if (lista.hidden) render(); destacar(ativo + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); destacar(ativo - 1); }
else if (e.key === "Enter") { if (!lista.hidden && ativo >= 0 && visiveis[ativo]) { e.preventDefault(); escolher(visiveis[ativo]); } else if (!hid.value) { e.preventDefault(); } }
else if (e.key === "Escape") { abrir(false); }
});
form.addEventListener("submit", function (e) {
if (!hid.value) { e.preventDefault(); erro.hidden = false; combo.classList.add("invalido"); box.focus(); }
});
})();
</script>
{% endif %}
</section>
+4 -4
View File
@@ -162,7 +162,7 @@ def test_full_installation_flow_creates_a_working_agent(hub):
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
"display_name": "Servidor Principal", "expected_hostname": "SRV-UNIAO",
"release_id": release_id}, follow_redirects=False)
assert created.status_code == 303
@@ -221,7 +221,7 @@ def test_direct_authenticated_download_does_not_need_a_link(hub):
client, factory = hub
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
"display_name": "Loja 2", "release_id": release_id}, follow_redirects=False)
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
@@ -303,7 +303,7 @@ def test_revoked_installation_link_rejected(hub):
client, factory = hub
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
uid = created.headers["location"].rsplit("/", 1)[-1]
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
@@ -322,7 +322,7 @@ def test_release_revoked_blocks_download(hub):
client, factory = hub
release_id = publish_release(factory)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
uid = created.headers["location"].rsplit("/", 1)[-1]
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
@@ -340,7 +340,7 @@ def test_panel_flow_code_generate_download_and_enroll_with_exe(hub, tmp_path):
client, factory = hub
release_id = publish_exe_release(factory, tmp_path)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
"display_name": "Servidor EXE", "release_id": release_id}, follow_redirects=False)
assert created.status_code == 303
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
@@ -391,7 +391,7 @@ def test_rotation_generates_a_code_that_invalidates_the_previous(hub, tmp_path):
client, factory = hub
release_id = publish_exe_release(factory, tmp_path)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
"display_name": "Rotação", "release_id": release_id}, follow_redirects=False)
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
first = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
@@ -414,7 +414,7 @@ def test_revoke_clears_code_and_is_audited(hub, tmp_path):
client, factory = hub
release_id = publish_exe_release(factory, tmp_path)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
"display_name": "Revoga", "release_id": release_id}, follow_redirects=False)
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)})
+34 -10
View File
@@ -23,8 +23,8 @@ def hub(tmp_path, monkeypatch):
factory = setup_db()
seed(factory) # tenant "Acme" (LOCAL, no source) + admin "operator"/"a-secure-password"
with factory() as session:
session.add(Tenant(name="Mercado Ativo", source="GTI_GERENCIADOR", external_client_id=1, active=True))
session.add(Tenant(name="Loja Inativa", source="GTI_GERENCIADOR", external_client_id=2, active=False))
session.add(Tenant(name="Mercado Ativo", source="GTI_GERENCIADOR", external_client_id=1, external_code="000001", external_cnpj="11222333000181", active=True))
session.add(Tenant(name="Loja <Inativa>", source="GTI_GERENCIADOR", external_client_id=2, external_cnpj="99888777000166", active=False))
release, _ = publish(session, write_temp_zip(make_release_zip(VERSION), VERSION), version=VERSION)
release_id = release.id
session.commit()
@@ -46,7 +46,7 @@ def test_new_installation_form_lists_only_active_gerenciador_tenants(hub):
login(client)
body = client.get("/agents/new").text
assert "Mercado Ativo" in body
assert "Loja Inativa" not in body # inactive: excluded from the selector
assert "Loja <Inativa>" not in body # inactive: excluded from the selector
assert "Acme" not in body # LOCAL (not from GTI Gerenciador): excluded
@@ -54,8 +54,8 @@ def test_cannot_create_installation_for_an_inactive_tenant(hub):
client, factory, release_id = hub
login(client)
with factory() as session:
inactive_id = session.scalar(select(Tenant).where(Tenant.name == "Loja Inativa")).id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": inactive_id, "display_name": "Teste", "release_id": release_id})
inactive_id = session.scalar(select(Tenant).where(Tenant.name == "Loja <Inativa>")).external_client_id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": inactive_id, "display_name": "Teste", "release_id": release_id})
assert response.status_code == 400 and "ativo" in response.text.lower()
with factory() as session:
assert session.scalar(select(AgentEnrollment)) is None
@@ -65,8 +65,10 @@ def test_cannot_create_installation_for_a_local_tenant(hub):
client, factory, release_id = hub
login(client)
with factory() as session:
local_id = session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": local_id, "display_name": "Teste", "release_id": release_id})
acme = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
assert acme.source == "LOCAL" and acme.external_client_id is None
local_id = 987654 # a LOCAL tenant has no external id, so nothing can ever match it
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": local_id, "display_name": "Teste", "release_id": release_id})
assert response.status_code == 400
with factory() as session:
assert session.scalar(select(AgentEnrollment)) is None
@@ -76,9 +78,31 @@ def test_creates_installation_for_an_active_gerenciador_tenant(hub):
client, factory, release_id = hub
login(client)
with factory() as session:
active_id = session.scalar(select(Tenant).where(Tenant.name == "Mercado Ativo")).id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": active_id, "display_name": "Teste", "release_id": release_id}, follow_redirects=False)
active = session.scalar(select(Tenant).where(Tenant.name == "Mercado Ativo")); active_id, tenant_pk = active.external_client_id, active.id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": active_id, "display_name": "Teste", "release_id": release_id}, follow_redirects=False)
assert response.status_code == 303
with factory() as session:
enr = session.scalar(select(AgentEnrollment))
assert enr is not None and enr.tenant_id == active_id
assert enr is not None and enr.tenant_id == tenant_pk
def test_combobox_markup_and_client_data(hub):
import json, re
client, _factory, _release_id = hub
login(client)
body = client.get("/agents/new").text
assert 'role="combobox"' in body and 'name="client_id"' in body and "<select name=\"tenant_id\"" not in body
assert "Selecione um cliente da lista." in body
data = json.loads(re.search(r'id="clientes-json">(.*?)</script>', body, re.S).group(1))
assert data == [{"id": 1, "name": "Mercado Ativo", "code": "000001", "cnpj": "11222333000181"}] # active only, real external id, cnpj for search
assert "Loja" not in body
def test_free_text_or_unknown_client_id_is_rejected(hub):
client, factory, release_id = hub
login(client)
for bad in ("", "Mercado Ativo", "999", "1; DROP"):
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": bad, "display_name": "T", "release_id": release_id})
assert response.status_code == 400 and "Selecione um cliente da lista." in response.text
with factory() as session:
assert session.scalar(select(AgentEnrollment)) is None
+5 -5
View File
@@ -1,7 +1,7 @@
{
"artifact": "RELEASE_MANIFEST",
"GTI_FISCAL_VERSION": "1.1.2",
"generated_at": "2026-09-24T01:54:42+00:00",
"generated_at": "2026-09-24T11:12:47+00:00",
"hub": {
"version": "1.1.2",
"api_compat": "1.0",
@@ -17,10 +17,10 @@
"ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1"
},
"migrations": {
"alembic_head_in_repo": "0024_tenant_gerenciador_mirror",
"applied_revision": "0024_tenant_gerenciador_mirror",
"applied_revision_source": "production Hub 10.0.10.117 (gti-fiscal-hub-fiscal-hub-1), verified 2026-09-24 (fresh DB == upgrade from 0023, pg_dump schema-identical)",
"count": 24
"alembic_head_in_repo": "0025_tenant_external_cnpj",
"applied_revision": "0025_tenant_external_cnpj",
"applied_revision_source": "production Hub 10.0.10.117 (gti-fiscal-hub-fiscal-hub-1), verified 2026-09-24",
"count": 25
},
"datasets_active": [
{