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
+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)