From 9f9e06e9e9b07dd9b3aa1bf6db1598451f58b95b Mon Sep 17 00:00:00 2001 From: gti Date: Thu, 24 Sep 2026 08:12:52 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20searchable=20client=20combobox=20in=20No?= =?UTF-8?q?va=20instala=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../versions/0025_tenant_external_cnpj.py | 20 +++++ central/app/client_sync.py | 5 +- central/app/models.py | 1 + central/app/web.py | 17 ++-- central/templates/agent_new_installation.html | 85 ++++++++++++++++--- central/tests/test_agent_distribution.py | 8 +- central/tests/test_install_code_enrollment.py | 6 +- .../tests/test_new_installation_clients.py | 44 +++++++--- release/RELEASE_MANIFEST.json | 10 +-- 9 files changed, 156 insertions(+), 40 deletions(-) create mode 100644 central/alembic/versions/0025_tenant_external_cnpj.py diff --git a/central/alembic/versions/0025_tenant_external_cnpj.py b/central/alembic/versions/0025_tenant_external_cnpj.py new file mode 100644 index 0000000..fc46451 --- /dev/null +++ b/central/alembic/versions/0025_tenant_external_cnpj.py @@ -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") diff --git a/central/app/client_sync.py b/central/app/client_sync.py index e5970c0..a0121e1 100644 --- a/central/app/client_sync.py +++ b/central/app/client_sync.py @@ -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 diff --git a/central/app/models.py b/central/app/models.py index 24f81f0..1306b5d 100644 --- a/central/app/models.py +++ b/central/app/models.py @@ -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) diff --git a/central/app/web.py b/central/app/web.py index a5e96f8..f0a3f3e 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -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 @@ -30,13 +34,72 @@

+ {% endif %} diff --git a/central/tests/test_agent_distribution.py b/central/tests/test_agent_distribution.py index 22a527e..db030dd 100644 --- a/central/tests/test_agent_distribution.py +++ b/central/tests/test_agent_distribution.py @@ -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) diff --git a/central/tests/test_install_code_enrollment.py b/central/tests/test_install_code_enrollment.py index 91aca4a..f6f49da 100644 --- a/central/tests/test_install_code_enrollment.py +++ b/central/tests/test_install_code_enrollment.py @@ -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)}) diff --git a/central/tests/test_new_installation_clients.py b/central/tests/test_new_installation_clients.py index 1221a53..48f22c5 100644 --- a/central/tests/test_new_installation_clients.py +++ b/central/tests/test_new_installation_clients.py @@ -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 ", 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 " 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 ")).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 "