feat: GTI Gerenciador as the single source of truth for clients; bump to 1.1.2
Tenant gains source (LOCAL|GTI_GERENCIADOR), external_client_id, external_code, active and synced_at (migration 0024). central/app/client_sync.py fetches from the panel's new GET /internal/fiscal/clients (dedicated bearer token, never MySQL credentials) and upserts Tenant rows, never touching a LOCAL row. /agents/new now offers only active GTI_GERENCIADOR tenants, with a client-side search box; the POST validates the same rule server-side, not just in the dropdown. central/scripts/sync_clients_from_gerenciador.py is the operator-run sync + reconciliation report. GTI_FISCAL_VERSION/AGENT_VERSION bumped to 1.1.2 (release/VERSION single source of truth), incorporating the installation_id persistence fix. Rebuilt the EXE installer trio at 1.1.2 (scripts/build_windows_installer.ps1). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""Client mirror from GTI Gerenciador — the single source of truth for
|
||||
clients (central/app/models.py: Tenant.source/external_client_id/active).
|
||||
The Fiscal Hub never receives MySQL credentials; it calls the panel's
|
||||
read-only internal endpoint instead, authenticated by a dedicated bearer
|
||||
token (FISCAL_INTERNAL_API_TOKEN, shared only between the two apps' envs
|
||||
— never SESSION_SECRET, the SSO key, an admin password or an Agent token).
|
||||
|
||||
Run `central/scripts/sync_clients_from_gerenciador.py` to pull a fresh copy;
|
||||
`Tenant.source='LOCAL'` rows (pre-existing dev/test tenants) are never
|
||||
touched or offered for a new installation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config
|
||||
from .models import Tenant
|
||||
|
||||
SOURCE = "GTI_GERENCIADOR"
|
||||
|
||||
|
||||
class ClientSyncUnavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(config.settings.gerenciador_internal_url and config.settings.fiscal_internal_api_token)
|
||||
|
||||
|
||||
def fetch_from_gerenciador() -> list[dict]:
|
||||
if not enabled():
|
||||
raise ClientSyncUnavailable("GERENCIADOR_INTERNAL_URL/FISCAL_INTERNAL_API_TOKEN not configured")
|
||||
headers = {"Authorization": f"Bearer {config.settings.fiscal_internal_api_token}"}
|
||||
try:
|
||||
response = httpx.get(config.settings.gerenciador_internal_url, headers=headers, timeout=config.settings.http_timeout_seconds)
|
||||
except httpx.HTTPError as error:
|
||||
raise ClientSyncUnavailable(f"request failed: {error}") from error
|
||||
if response.status_code != 200:
|
||||
raise ClientSyncUnavailable(f"unexpected status {response.status_code}: {response.text[:200]}")
|
||||
return response.json()["clients"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
fetched: int
|
||||
created: int
|
||||
updated: int
|
||||
unchanged: int
|
||||
active: int
|
||||
inactive: int
|
||||
|
||||
|
||||
def sync_from_gerenciador(session, clients: list[dict]) -> SyncResult:
|
||||
"""Upsert `clients` (each: external_client_id, code, display_name, active)
|
||||
into Tenant rows with source='GTI_GERENCIADOR'. Never touches a
|
||||
source='LOCAL' row. A display-name collision with another tenant is
|
||||
disambiguated with the external code rather than failing the whole sync
|
||||
over one client (Tenant.name is UNIQUE)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
existing_by_external = {row.external_client_id: row for row in session.scalars(select(Tenant).where(Tenant.source == SOURCE)).all()}
|
||||
taken_names = {row.name for row in session.scalars(select(Tenant)).all()}
|
||||
created = updated = unchanged = 0
|
||||
|
||||
for client in clients:
|
||||
external_id = client["external_client_id"]
|
||||
code = client["code"]
|
||||
active = bool(client["active"])
|
||||
wanted_name = (client.get("display_name") or "").strip() or f"Cliente {code}"
|
||||
row = existing_by_external.get(external_id)
|
||||
|
||||
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)
|
||||
session.add(row)
|
||||
taken_names.add(name)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
changed = False
|
||||
if row.name != wanted_name and wanted_name not in taken_names:
|
||||
taken_names.discard(row.name)
|
||||
row.name = wanted_name
|
||||
taken_names.add(wanted_name)
|
||||
changed = True
|
||||
if row.external_code != code:
|
||||
row.external_code = code
|
||||
changed = True
|
||||
if row.active != active:
|
||||
row.active = active
|
||||
changed = True
|
||||
row.synced_at = now
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
session.flush()
|
||||
active_count = sum(1 for c in clients if c["active"])
|
||||
return SyncResult(fetched=len(clients), created=created, updated=updated, unchanged=unchanged, active=active_count, inactive=len(clients) - active_count)
|
||||
@@ -52,6 +52,11 @@ class Settings:
|
||||
agent_download_link_ttl_seconds: int = int(os.getenv("AGENT_DOWNLOAD_LINK_TTL_SECONDS", "1800"))
|
||||
agent_download_max_uses_default: int = int(os.getenv("AGENT_DOWNLOAD_MAX_USES_DEFAULT", "1"))
|
||||
agent_enrollment_ttl_seconds: int = int(os.getenv("AGENT_ENROLLMENT_TTL_SECONDS", "86400"))
|
||||
# ---- Client mirror from GTI Gerenciador (central/app/client_sync.py) ----
|
||||
# GTI Gerenciador is the single source of truth for clients; the Fiscal Hub
|
||||
# never gets MySQL credentials. Both empty = sync disabled (documented).
|
||||
gerenciador_internal_url: str = os.getenv("GERENCIADOR_INTERNAL_URL", "") # e.g. https://gtigerenciador.../internal/fiscal/clients
|
||||
fiscal_internal_api_token: str = os.getenv("FISCAL_INTERNAL_API_TOKEN", "")
|
||||
ncm_dataset_url: str = os.getenv("NCM_DATASET_URL", "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json")
|
||||
cest_dataset_url: str = os.getenv("CEST_DATASET_URL", "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18")
|
||||
dataset_storage_path: str = os.getenv("DATASET_STORAGE_PATH", "./data/datasets")
|
||||
|
||||
@@ -215,9 +215,22 @@ class RtcCredPresEntry(Base):
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""A client. GTI Gerenciador (the panel) is the single source of truth —
|
||||
see central/app/client_sync.py. `source='GTI_GERENCIADOR'` rows are
|
||||
upserted by that sync, keyed by `external_client_id` (Gerenciador's
|
||||
CLIENTE.ID, the stable technical PK — CLIENTE.CODIGO is editable and
|
||||
never used as identity, same reasoning as FUNCIONARIO_PAINEL.ID in SSO).
|
||||
`source='LOCAL'` rows predate this sync (dev/test tenants) and are never
|
||||
offered for a new installation."""
|
||||
__tablename__ = "tenants"
|
||||
__table_args__ = (UniqueConstraint("source", "external_client_id", name="uq_tenant_external_identity"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
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)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
|
||||
@@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds
|
||||
once published — never a second, independent version number. Fiscal semantics
|
||||
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
|
||||
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
|
||||
GTI_FISCAL_VERSION = "1.1.1"
|
||||
GTI_FISCAL_VERSION = "1.1.2"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
|
||||
|
||||
+19
-7
@@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import agent_distribution, config, enrollment, sso
|
||||
from . import agent_distribution, client_sync, config, enrollment, sso
|
||||
from .config import settings
|
||||
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
|
||||
from .context_keys import ALLOWED_KEYS, ENUMS
|
||||
@@ -200,14 +200,22 @@ def router(session_factory):
|
||||
return RedirectResponse("/agents/releases", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
def _active_gerenciador_tenants(session, search: str | None = None):
|
||||
# GTI Gerenciador is the single source of truth for clients (central/app/
|
||||
# client_sync.py) — a new installation never offers a LOCAL (dev/test) or
|
||||
# inactive tenant, so it can never pick a client that doesn't exist there.
|
||||
query = select(Tenant).where(Tenant.source == client_sync.SOURCE, Tenant.active.is_(True))
|
||||
if search: query = query.where(Tenant.name.ilike(f"%{search}%"))
|
||||
return session.scalars(query.order_by(Tenant.name)).all()
|
||||
|
||||
@routes.get("/agents/new", response_class=HTMLResponse)
|
||||
def new_installation_form(request: Request):
|
||||
def new_installation_form(request: Request, search: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
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})
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None, "search": search or ""})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/new")
|
||||
@@ -224,13 +232,17 @@ def router(session_factory):
|
||||
errors = []
|
||||
tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None
|
||||
release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None
|
||||
if not tenant: errors.append("Selecione um cliente válido.")
|
||||
# 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 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 = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
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)}, 400)
|
||||
return render(request, "agent_new_installation.html", {"tenants": 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)
|
||||
|
||||
Reference in New Issue
Block a user