722 lines
54 KiB
Python
722 lines
54 KiB
Python
"""Server-rendered operator pages for the GTI Fiscal control panel."""
|
|
|
|
import secrets
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from urllib.parse import parse_qs, urlencode
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import func, select, update
|
|
|
|
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
|
|
from .installation_identity import reference_agent_version
|
|
from .panel_presentation import ENRICHMENT_MODES, ENRICHMENT_MODE_LABELS, RTC_MODE_EXPLANATION, RTC_MODE_LABELS, abort_reason_label, audit_outcome_label, candidate_status_label, catalog_state_label, job_kind_label, job_status_label, rtc_rule_label, rtc_status_view
|
|
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events, rtc_blocker_summary
|
|
from .human_auth import create_session, current_session, require_csrf, verify_password
|
|
from .panel_presentation import format_ui_datetime
|
|
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, ProductEnrichment, ProductFieldAudit, RtcCandidate, RtcWriteAudit, Tenant
|
|
from .jobs import create_job
|
|
from .audit import record_audit
|
|
|
|
|
|
def router(session_factory):
|
|
routes = APIRouter()
|
|
templates = Jinja2Templates(directory="central/templates")
|
|
|
|
def auth(request, session):
|
|
return current_session(request, session)
|
|
|
|
def render(request, template, context=None, status_code=200):
|
|
values = {"request": request, "csrf_token": request.cookies.get("gti_csrf", ""), "panel_home": sso.panel_home(), **(context or {})}
|
|
return templates.TemplateResponse(request=request, name=template, context=values, status_code=status_code)
|
|
|
|
def client_ip(request):
|
|
return request.client.host if request.client else None
|
|
|
|
def _aware(value: datetime | None) -> datetime | None:
|
|
"""SQLite (used by the test suite) drops tzinfo on round-trip even for
|
|
a DateTime(timezone=True) column; Postgres never does. Normalize to
|
|
UTC before comparing against datetime.now(timezone.utc)."""
|
|
if value is None or value.tzinfo is not None:
|
|
return value
|
|
return value.replace(tzinfo=timezone.utc)
|
|
|
|
def set_session_cookies(response, token, csrf, seconds):
|
|
cfg = config.settings
|
|
response.set_cookie(cfg.web_cookie_name, token, max_age=seconds, httponly=True, secure=cfg.web_cookie_secure, samesite="lax")
|
|
response.set_cookie("gti_csrf", csrf, max_age=seconds, httponly=False, secure=cfg.web_cookie_secure, samesite="lax")
|
|
|
|
def query_base(**values):
|
|
return urlencode({key: value for key, value in values.items() if value not in (None, "")})
|
|
|
|
def date_filter(value, *, end_of_day=False):
|
|
if not value:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value)
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.replace(hour=23, minute=59, second=59, microsecond=999999) if end_of_day and len(value) == 10 else parsed
|
|
except ValueError:
|
|
return None
|
|
|
|
@routes.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request, next: str | None = None):
|
|
"""Collaborators never see a Hub login: with SSO configured this starts
|
|
the flow at GTI Gerenciador (which shows its own login if needed)."""
|
|
if not sso.enabled():
|
|
return render(request, "login.html", {"error": None, "emergency": False})
|
|
cfg = config.settings
|
|
state = secrets.token_urlsafe(24)
|
|
response = RedirectResponse(f"{cfg.sso_start_url}?{urlencode({'state': state, 'next': sso.safe_next(next)})}", status_code=303)
|
|
# Sent back on the cross-site POST from the panel, hence SameSite=None (+Secure).
|
|
response.set_cookie(sso.STATE_COOKIE, state, max_age=sso.STATE_SECONDS, path=sso.STATE_COOKIE_PATH, httponly=True,
|
|
secure=cfg.web_cookie_secure, samesite="none" if cfg.web_cookie_secure else "lax")
|
|
return response
|
|
|
|
@routes.get("/login/local", response_class=HTMLResponse)
|
|
def emergency_login_page(request: Request):
|
|
"""Break-glass only (local AdminUser with a password). Not linked from the UI."""
|
|
return render(request, "login.html", {"error": None, "emergency": True})
|
|
|
|
@routes.post("/login")
|
|
async def login(request: Request):
|
|
values = await form_values(request)
|
|
username = values.get("username", "").strip().lower()
|
|
with session_factory() as session:
|
|
admin = session.scalar(select(AdminUser).where(AdminUser.username == username))
|
|
if not admin or not admin.is_active or admin.auth_source != "LOCAL" or not verify_password(values.get("password", ""), admin.password_hash):
|
|
record_audit(session, admin_user_id=None, action="LOGIN_LOCAL_FAILURE", target_type="admin_user", target_id=username[:128] or "-", context={"ip": client_ip(request)}, severity="WARNING")
|
|
session.commit()
|
|
return render(request, "login.html", {"error": "Credenciais inválidas", "emergency": True}, 401)
|
|
token, csrf = create_session(session, admin, request)
|
|
record_audit(session, admin_user_id=admin.id, action="LOGIN_LOCAL_SUCCESS", target_type="admin_user", target_id=admin.username, context={"ip": client_ip(request)}, severity="WARNING")
|
|
session.commit()
|
|
response = RedirectResponse("/", status_code=303)
|
|
set_session_cookies(response, token, csrf, config.settings.web_session_seconds)
|
|
return response
|
|
|
|
@routes.post("/auth/sso/callback")
|
|
async def sso_callback(request: Request):
|
|
values = await form_values(request)
|
|
with session_factory() as session:
|
|
try:
|
|
if not sso.enabled():
|
|
raise sso.SsoRejected("SSO_DISABLED")
|
|
identity = sso.verify_assertion(values.get("assertion"), request.cookies.get(sso.STATE_COOKIE))
|
|
sso.consume_jti(session, identity)
|
|
user = sso.provision(session, identity)
|
|
seconds = config.settings.federated_session_seconds
|
|
token, csrf = create_session(session, user, request, seconds=seconds)
|
|
record_audit(session, admin_user_id=user.id, action="SSO_LOGIN_SUCCESS", target_type="admin_user", target_id=identity.subject,
|
|
context={"ip": client_ip(request), "login": identity.login, "role": identity.role})
|
|
session.commit()
|
|
except sso.SsoRejected as rejected:
|
|
session.rollback()
|
|
record_audit(session, admin_user_id=None, action="SSO_LOGIN_REJECTED", target_type="sso", target_id=rejected.subject or "-",
|
|
context={"ip": client_ip(request), "reason": rejected.reason}, severity="WARNING")
|
|
session.commit()
|
|
response = render(request, "sso_error.html", {"reason": rejected.reason}, 401)
|
|
response.delete_cookie(sso.STATE_COOKIE, path=sso.STATE_COOKIE_PATH)
|
|
return response
|
|
response = RedirectResponse(sso.safe_next(values.get("next")), status_code=303)
|
|
set_session_cookies(response, token, csrf, seconds)
|
|
response.delete_cookie(sso.STATE_COOKIE, path=sso.STATE_COOKIE_PATH)
|
|
return response
|
|
|
|
@routes.post("/logout")
|
|
async def logout(request: Request):
|
|
"""Ends only the Hub session. Coming back through GTI Gerenciador signs in
|
|
again without a password (documented in docs/PILOT_DEPLOYMENT.md)."""
|
|
with session_factory() as session:
|
|
web_session, admin = current_session(request, session)
|
|
if web_session:
|
|
values = await form_values(request)
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
record_audit(session, admin_user_id=admin.id, action="LOGOUT", target_type="admin_user", target_id=admin.external_subject or admin.username, context={"ip": client_ip(request)})
|
|
session.delete(web_session); session.commit()
|
|
response = render(request, "logged_out.html", {}) if sso.enabled() else RedirectResponse("/login", status_code=303)
|
|
response.delete_cookie(config.settings.web_cookie_name); response.delete_cookie("gti_csrf")
|
|
return response
|
|
|
|
def page_auth(request):
|
|
session = session_factory()
|
|
web_session, admin = current_session(request, session)
|
|
if not admin:
|
|
session.close()
|
|
wanted = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
|
return None, None, RedirectResponse(f"/login?{urlencode({'next': wanted})}", status_code=303)
|
|
return session, (web_session, admin), None
|
|
|
|
async def form_values(request):
|
|
return {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items}
|
|
|
|
@routes.get("/", response_class=HTMLResponse)
|
|
def dashboard(request: Request):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try: return render(request, "dashboard.html", {"summary": dashboard_summary(session), "auth": auth_data})
|
|
finally: session.close()
|
|
|
|
@routes.get("/agents", response_class=HTMLResponse)
|
|
def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None, sort: str = "display_name", scope: str = "active", version: str | None = None):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
scope = scope if scope in ("active", "all") else "active"
|
|
health = health if health in ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN") else None
|
|
sort = sort if sort in ("display_name", "worst_coverage") else "display_name"
|
|
version = version if version == "outdated" else None
|
|
return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort, scope=scope, version=version), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "scope_filter": scope, "version_filter": version or "", "query_base": query_base(search=search, health=health, sort=sort, scope=scope, version=version)})
|
|
finally: session.close()
|
|
|
|
# --- Static /agents/* routes MUST be declared before /agents/{agent_id}:
|
|
# Starlette matches in registration order, so a dynamic route declared
|
|
# first would capture "/agents/new" and "/agents/releases" and fail the
|
|
# int conversion with a 422 (the production bug this ordering prevents).
|
|
@routes.get("/agents/releases", response_class=HTMLResponse)
|
|
def agent_releases(request: Request):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
releases = session.scalars(select(AgentRelease).order_by(AgentRelease.created_at.desc())).all()
|
|
counts = {}
|
|
for release_id, in session.execute(select(AgentEnrollment.release_id)).all():
|
|
counts[release_id] = counts.get(release_id, 0) + 1
|
|
return render(request, "agent_releases.html", {"releases": releases, "installation_counts": counts, "auth": auth_data})
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/releases/{release_id}/revoke")
|
|
async def revoke_release(request: Request, release_id: int):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
release = session.get(AgentRelease, release_id)
|
|
if not release: return HTMLResponse("Release not found", status_code=404)
|
|
release.status = "REVOKED"
|
|
record_audit(session, admin_user_id=admin.id, action="AGENT_RELEASE_REVOKED", target_type="agent_release", target_id=release.version, context={"platform": release.platform, "architecture": release.architecture})
|
|
session.commit()
|
|
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()
|
|
|
|
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)
|
|
if redirect: return redirect
|
|
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, "clients_json": _clients_json(tenants), "releases": releases, "auth": auth_data, "error": None, "search": search or ""})
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/new")
|
|
async def create_pending_installation(request: Request):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
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.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 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, "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)
|
|
if values.get("features_selected") == "1": # chosen explicitly at creation; otherwise the installation waits for an admin (WAITING_FEATURE_CONFIGURATION)
|
|
mode = (values.get("feature_enrichment_mode") or "DISABLED").strip().upper()
|
|
enr.features_selected, enr.feature_corrector = True, values.get("feature_corrector") == "1"
|
|
enr.feature_enrichment_mode = mode if mode in ENRICHMENT_MODES else "DISABLED"
|
|
session.add(enr)
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=tenant.id, action="INSTALLATION_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"display_name": display_name, "release_version": release.version, "features_selected": bool(enr.features_selected), "corrector": bool(enr.feature_corrector), "enrichment_mode": enr.feature_enrichment_mode})
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
|
finally: session.close()
|
|
|
|
@routes.get("/agents/{agent_id}", response_class=HTMLResponse)
|
|
def agent_detail(request: Request, agent_id: str, tab: str = "overview", event_page: int = 1, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None, since: str | None = None, until: str | None = None, enrich_code: str | None = None):
|
|
# agent_id is parsed here (not typed int in the path) so a non-numeric
|
|
# slug like /agents/abc gets a controlled HTML 404 instead of a raw
|
|
# 422 — and can never shadow the static routes above.
|
|
try:
|
|
agent_id = int(agent_id)
|
|
except ValueError:
|
|
return HTMLResponse("Agent não encontrado (identificador inválido)", status_code=404)
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
row = session.execute(select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(Agent.id == agent_id)).first()
|
|
if row is None: return HTMLResponse("Agent not found", status_code=404)
|
|
installation, agent, tenant, snapshot = row
|
|
detail = installation_view(installation, agent, tenant, snapshot, reference=reference_agent_version(session))
|
|
event_page = max(1, event_page)
|
|
events = list_panel_events(session, installation.id, page=event_page, page_size=25, include_more=True, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=date_filter(since), until=date_filter(until, end_of_day=True))
|
|
has_more = len(events) > 25
|
|
events = events[:25]
|
|
for event in events:
|
|
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
|
|
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25, scope="all")
|
|
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent), "features": _features_view(session, installation, enrich_code), "enrich_code": enrich_code or "", "is_admin": _is_privileged(auth_data[1])})
|
|
finally: session.close()
|
|
|
|
def _features_view(session, installation, enrich_code=None):
|
|
"""FUNCIONALIDADES + small ENRIQUECIMENTO summary of the Agent detail (no separate dashboard)."""
|
|
counts = dict(session.execute(select(ProductEnrichment.status, func.count()).where(ProductEnrichment.installation_id == installation.id).group_by(ProductEnrichment.status)).all())
|
|
applied = session.scalar(select(func.count()).select_from(ProductFieldAudit).where(ProductFieldAudit.installation_id == installation.id, ProductFieldAudit.outcome == "APPLIED")) or 0
|
|
lookup = None
|
|
if enrich_code:
|
|
row = session.scalar(select(ProductEnrichment).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.product_code == enrich_code.strip()[:64]))
|
|
audits = session.scalars(select(ProductFieldAudit).where(ProductFieldAudit.installation_id == installation.id, ProductFieldAudit.product_code == enrich_code.strip()[:64]).order_by(ProductFieldAudit.id.desc()).limit(20)).all() if row else []
|
|
lookup = {"found": row is not None, "row": row, "audits": audits}
|
|
mode = installation.enrichment_mode
|
|
waiting = not installation.features_configured
|
|
return {"configured": bool(installation.features_configured), "waiting": waiting, "corrector_enabled": bool(installation.corrector_enabled), "enrichment_mode": mode,
|
|
"mode_label": ENRICHMENT_MODE_LABELS.get(mode, mode), "counts": counts, "total": sum(counts.values()), "applied": applied, "lookup": lookup,
|
|
"state_label": "Aguardando configuração de funcionalidades" if waiting else ENRICHMENT_MODE_LABELS.get(mode, mode)}
|
|
|
|
@routes.post("/agents/{agent_id}/features")
|
|
async def set_features(request: Request, agent_id: int):
|
|
"""Per-installation functions (ADMIN only, CSRF, audited). Central decision: the Agent has no local switch."""
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
agent, installation = _rtc_target(session, agent_id)
|
|
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
|
if not _is_privileged(admin):
|
|
return _deny(session, admin, installation.tenant_id, "features:set", installation.installation_id)
|
|
mode = (values.get("enrichment_mode") or "").strip().upper()
|
|
if mode not in ENRICHMENT_MODES:
|
|
return HTMLResponse("modo inválido", status_code=422)
|
|
if mode == "ENABLED" and (values.get("confirm") or "").strip().upper() != "PREENCHER":
|
|
return HTMLResponse("Digite PREENCHER para confirmar o enriquecimento automático", status_code=422)
|
|
before = {"corrector": bool(installation.corrector_enabled), "enrichment_mode": installation.enrichment_mode, "configured": bool(installation.features_configured)}
|
|
installation.corrector_enabled = values.get("corrector_enabled") == "1"
|
|
installation.enrichment_mode = mode
|
|
installation.features_configured = True
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="INSTALLATION_FEATURES", target_type="installation", target_id=installation.installation_id,
|
|
context={"from": before, "to": {"corrector": installation.corrector_enabled, "enrichment_mode": mode}, "operator": admin.display_name or admin.username, "role": admin.external_role or admin.auth_source}, severity="WARNING")
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/{agent_id}", status_code=303)
|
|
finally: session.close()
|
|
|
|
def _ops_view(session, installation, agent):
|
|
"""Processing status shown on the Agent detail (no clutter: 5 facts)."""
|
|
last_job = session.scalar(select(Job).where(Job.agent_id == agent.id).order_by(Job.id.desc()).limit(1))
|
|
last_sync = _aware(installation.last_sync_at)
|
|
next_sync = None
|
|
if installation.processing_enabled and last_sync is not None:
|
|
next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds)
|
|
last_summary = session.scalar(select(Job.result_summary).where(Job.agent_id == agent.id, Job.status == "COMPLETED", Job.kind.in_(("INITIAL_SYNC", "SYNC_INCREMENTAL", "REPROCESS_ALL"))).order_by(Job.id.desc()).limit(1))
|
|
return {"rtc": rtc_status_view(last_summary, installation.rtc_write_mode), "rtc_mode": installation.rtc_write_mode, "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60,
|
|
"last_sync": format_ui_datetime(last_sync) if last_sync else None, "next_sync": format_ui_datetime(next_sync) if next_sync else None,
|
|
"last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id, "kind_label": job_kind_label(last_job.kind), "status_label": job_status_label(last_job.status)} if last_job else None}
|
|
|
|
async def _installation_action(request, agent_id, action):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
agent = session.get(Agent, agent_id)
|
|
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
|
|
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
|
if action == "pause": installation.processing_enabled = False
|
|
elif action == "resume": installation.processing_enabled = True
|
|
elif action == "sync-now":
|
|
job = create_job(session, agent, "SYNC_INCREMENTAL", {"reason": "MANUAL_SYNC_NOW", "requested_by": admin.id})
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="SYNC_NOW", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "kind": job.kind})
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"PROCESSING_{action.upper().replace('-', '_')}", target_type="installation", target_id=installation.installation_id, context={"processing_enabled": installation.processing_enabled}) if action in ("pause", "resume") else None
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
|
|
finally: session.close()
|
|
|
|
def _is_privileged(admin) -> bool:
|
|
"""Who may change what the Agent is allowed to WRITE. The identity comes from the Gerenciador SSO (`perfil`): only ADMIN. LOCAL break-glass accounts are administrators by construction.
|
|
A normal (PADRAO) collaborator can look at the control page and use DISABLED/DRY_RUN (read-only) but cannot approve, enable, apply or roll back."""
|
|
return admin.auth_source == "LOCAL" or (admin.external_role or "").upper() == "ADMIN"
|
|
|
|
def _deny(session, admin, tenant_id, action, target):
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=tenant_id, action="RTC_DENIED", target_type="installation", target_id=target, context={"attempted": action, "role": admin.external_role or admin.auth_source}, severity="WARNING")
|
|
session.commit()
|
|
return HTMLResponse("Ação restrita a administradores do GTI Gerenciador (perfil ADMIN).", status_code=403)
|
|
|
|
def _rtc_target(session, agent_id):
|
|
agent = session.get(Agent, agent_id)
|
|
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
|
|
return agent, installation
|
|
|
|
@routes.get("/agents/{agent_id}/rtc")
|
|
async def rtc_control_page(request: Request, agent_id: int):
|
|
"""Central cClassTrib control: write mode, candidate preview (server-side pagination), approvals and audit/rollback."""
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
agent, installation = _rtc_target(session, agent_id)
|
|
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
|
q = request.query_params
|
|
status = q.get("status") or "PENDING"
|
|
page = max(1, int(q.get("page", "1") or 1)) if str(q.get("page", "1")).isdigit() else 1
|
|
size = 50
|
|
base = select(RtcCandidate).where(RtcCandidate.installation_id == installation.id, RtcCandidate.rule_id == "RTC-R3")
|
|
counts = dict(session.execute(select(RtcCandidate.status, func.count()).where(RtcCandidate.installation_id == installation.id, RtcCandidate.rule_id == "RTC-R3").group_by(RtcCandidate.status)).all())
|
|
shown = base.where(RtcCandidate.status == status) if status in ("PENDING", "APPROVED", "REJECTED", "APPLIED", "STALE") else base
|
|
total = int(session.scalar(select(func.count()).select_from(shown.subquery())) or 0)
|
|
rows = list(session.scalars(shown.order_by(RtcCandidate.product_code).offset((page - 1) * size).limit(size)))
|
|
audits = list(session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.outcome.in_(("APPLIED", "ROLLED_BACK"))).order_by(RtcWriteAudit.id.desc()).limit(25)))
|
|
ops = _ops_view(session, installation, agent)
|
|
rtc_blockers = rtc_blocker_summary(session, agent_id)
|
|
rtc_products = ((ops.get("rtc") or {}).get("products") or {})
|
|
r3_candidates = int(rtc_products.get("ready", counts.get("PENDING", 0) + counts.get("APPROVED", 0)))
|
|
blocked_products = max(int(rtc_products.get("blocked", 0)), rtc_blockers["products_affected"])
|
|
reasons = rtc_blockers["reason_counts"]
|
|
simple_reason_count = sum(count for reason, count in reasons.items() if reason.endswith("SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT"))
|
|
simple_reason_count = max(simple_reason_count, int(rtc_products.get("simple_orientation_count", 0)))
|
|
other_reason_max = max((count for reason, count in reasons.items() if not reason.endswith("SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT")), default=0)
|
|
simple_orientation_dominant = bool(rtc_products.get("simple_orientation_dominant")) or simple_reason_count > other_reason_max
|
|
rtc_empty_state = {"show": r3_candidates == 0 and blocked_products > 0, "r3_candidates": r3_candidates,
|
|
"blocked_products": blocked_products, "simple_orientation_dominant": simple_orientation_dominant}
|
|
return render(request, "rtc_control.html", {"agent_id": agent_id, "installation": installation, "mode": installation.rtc_write_mode, "modes": RTC_MODE_LABELS, "explanation": RTC_MODE_EXPLANATION, "ops": ops,
|
|
"privileged": _is_privileged(auth_data[1]), "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits, "rtc_empty_state": rtc_empty_state,
|
|
"L": {"status": candidate_status_label, "outcome": audit_outcome_label, "reason": abort_reason_label, "rule": rtc_rule_label, "catalog": catalog_state_label}})
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/{agent_id}/rtc-mode")
|
|
async def rtc_write_mode(request: Request, agent_id: int):
|
|
"""Central authorization of the cClassTrib fill, per installation. Default DISABLED; ENABLED needs a typed confirmation; every change is audited."""
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
agent, installation = _rtc_target(session, agent_id)
|
|
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
|
mode = (values.get("mode") or "").strip().upper()
|
|
if mode not in RTC_MODE_LABELS:
|
|
return HTMLResponse("modo inválido", status_code=422)
|
|
if mode not in ("DISABLED", "DRY_RUN") and not _is_privileged(admin):
|
|
return _deny(session, admin, installation.tenant_id, f"rtc-mode:{mode}", installation.installation_id)
|
|
if installation.rtc_write_mode not in ("DISABLED", "DRY_RUN") and not _is_privileged(admin):
|
|
return _deny(session, admin, installation.tenant_id, f"rtc-mode:leave:{installation.rtc_write_mode}", installation.installation_id)
|
|
if mode == "ENABLED" and (values.get("confirm") or "").strip().upper() != "PREENCHER":
|
|
return HTMLResponse("Digite PREENCHER para confirmar o modo automático", status_code=422)
|
|
previous = installation.rtc_write_mode
|
|
installation.rtc_write_mode = mode
|
|
installation.rtc_auto_apply_authorized = mode == "ENABLED"
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_WRITE_MODE", target_type="installation", target_id=installation.installation_id,
|
|
context={"from": previous, "to": mode, "operator": admin.display_name or admin.username, "role": admin.external_role or admin.auth_source}, severity="WARNING")
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/{agent_id}/rtc-candidates")
|
|
async def rtc_candidate_action(request: Request, agent_id: int):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
agent, installation = _rtc_target(session, agent_id)
|
|
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
|
action = values.get("action")
|
|
if not _is_privileged(admin):
|
|
return _deny(session, admin, installation.tenant_id, f"rtc-candidates:{action}", installation.installation_id)
|
|
scope = RtcCandidate.installation_id == installation.id
|
|
ids = [int(x) for x in parse_qs((await request.body()).decode()).get("ids", []) if x.isdigit()]
|
|
select_all = values.get("all") == "1" or request.query_params.get("all") == "1"
|
|
changed = 0
|
|
if action in ("approve", "reject"):
|
|
new, old = ("APPROVED", ("PENDING",)) if action == "approve" else ("REJECTED", ("PENDING", "APPROVED"))
|
|
cond = scope & RtcCandidate.status.in_(old) & (RtcCandidate.id.in_(ids) if not select_all else RtcCandidate.status.in_(old))
|
|
changed = session.execute(update(RtcCandidate).where(cond).values(status=new)).rowcount
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all, "operator": admin.display_name or admin.username}, severity="WARNING")
|
|
elif action == "apply_approved":
|
|
if installation.rtc_write_mode != "APPROVAL_REQUIRED":
|
|
return HTMLResponse("Disponível apenas no modo Aprovação manual", status_code=409)
|
|
codes = list(session.scalars(select(RtcCandidate.product_code).where(scope, RtcCandidate.status == "APPROVED").order_by(RtcCandidate.product_code).limit(500)))
|
|
if codes:
|
|
job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": codes, "reason": "RTC_APPLY_APPROVED", "requested_by": admin.id})
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes), "operator": admin.display_name or admin.username}, severity="WARNING")
|
|
else:
|
|
return HTMLResponse("ação inválida", status_code=422)
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/{agent_id}/rtc-rollback")
|
|
async def rtc_rollback_request(request: Request, agent_id: int):
|
|
"""Requests the rollback of ONE audit record. The Agent executes it only if the value in the ERP still equals what was written; no free SQL exists."""
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
agent, installation = _rtc_target(session, agent_id)
|
|
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
|
if not _is_privileged(admin):
|
|
return _deny(session, admin, installation.tenant_id, "rtc-rollback", installation.installation_id)
|
|
if (values.get("confirm") or "").strip().upper() != "REVERTER":
|
|
return HTMLResponse("Digite REVERTER para confirmar", status_code=422)
|
|
audit = session.get(RtcWriteAudit, int(values.get("audit_id") or 0))
|
|
if audit is None or audit.installation_id != installation.id or audit.action != "APPLY" or audit.outcome != "APPLIED" or audit.rollback_state in ("REQUESTED", "DONE"):
|
|
return HTMLResponse("registro não reversível", status_code=409)
|
|
audit.rollback_state = "REQUESTED"
|
|
job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": [audit.product_code], "reason": "RTC_ROLLBACK", "requested_by": admin.id})
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_ROLLBACK_REQUESTED", target_type="installation", target_id=installation.installation_id,
|
|
context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id, "operator": admin.display_name or admin.username}, severity="WARNING")
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/{agent_id}/sync-now")
|
|
async def sync_now(request: Request, agent_id: int):
|
|
return await _installation_action(request, agent_id, "sync-now")
|
|
|
|
@routes.post("/agents/{agent_id}/pause")
|
|
async def pause_processing(request: Request, agent_id: int):
|
|
return await _installation_action(request, agent_id, "pause")
|
|
|
|
@routes.post("/agents/{agent_id}/resume")
|
|
async def resume_processing(request: Request, agent_id: int):
|
|
return await _installation_action(request, agent_id, "resume")
|
|
|
|
@routes.get("/agents/{agent_id}/reprocess-confirmation", response_class=HTMLResponse)
|
|
def reprocess_confirmation(request: Request, agent_id: int):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
if not session.scalar(select(Agent.id).where(Agent.id == agent_id)):
|
|
return HTMLResponse("Agent not found", status_code=404)
|
|
return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data})
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/{agent_id}/reprocess")
|
|
async def reprocess_agent(request: Request, agent_id: int):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
if values.get("confirm") != "yes": return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data, "error": "Confirme explicitamente o reprocessamento."}, 400)
|
|
agent = session.get(Agent, agent_id)
|
|
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
|
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
|
existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == "REPROCESS_ALL", Job.status == "PENDING")).all() if candidate.payload == {}), None)
|
|
job = existing or create_job(session, agent, "REPROCESS_ALL", {})
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="REPROCESS_ALL", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id})
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
|
|
finally: session.close()
|
|
|
|
def _pending_or_404(session, enrollment_uuid):
|
|
return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid))
|
|
|
|
@routes.get("/agents/pending/{enrollment_uuid}", response_class=HTMLResponse)
|
|
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None, code: str | None = None):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
enr = _pending_or_404(session, enrollment_uuid)
|
|
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
|
release = session.get(AgentRelease, enr.release_id)
|
|
tenant = session.get(Tenant, enr.tenant_id)
|
|
installed_agent = session.get(Agent, enr.agent_id) if enr.agent_id else None
|
|
active_code = bool(enr.install_code_hash and enr.install_code_expires_at and _aware(enr.install_code_expires_at) > datetime.now(timezone.utc))
|
|
code_expires_at = enr.install_code_expires_at if active_code else None
|
|
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent,
|
|
"download_link": link, "install_code": code, "code_active": active_code,
|
|
"code_expires_at": code_expires_at, "auth": auth_data})
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/pending/{enrollment_uuid}/link")
|
|
async def create_download_link(request: Request, enrollment_uuid: str):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
enr = _pending_or_404(session, enrollment_uuid)
|
|
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
|
if enr.status in ("ENROLLED", "REVOKED"): return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
|
token = enrollment.new_token()
|
|
enr.download_token_hash = enrollment.token_digest(token)
|
|
enr.download_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=config.settings.agent_download_link_ttl_seconds)
|
|
enr.download_max_uses = config.settings.agent_download_max_uses_default
|
|
enr.download_uses = 0
|
|
enr.status = "LINK_CREATED"
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="DOWNLOAD_LINK_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_download_link_ttl_seconds, "ip": client_ip(request)})
|
|
session.commit()
|
|
url = f"{config.settings.base_url}/dl/agent/{token}"
|
|
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?link={url}", status_code=303)
|
|
finally: session.close()
|
|
|
|
@routes.get("/agents/pending/{enrollment_uuid}/download")
|
|
def direct_download(request: Request, enrollment_uuid: str):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
web_session, admin = auth_data
|
|
enr = _pending_or_404(session, enrollment_uuid)
|
|
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
|
if enr.status == "REVOKED": return HTMLResponse("Installation revoked", status_code=409)
|
|
try:
|
|
return agent_distribution.build_and_serve(session, enr, request, actor_admin_id=admin.id)
|
|
except enrollment.EnrollmentRejected as rejected:
|
|
return HTMLResponse(f"Download indisponível: {rejected.reason}", status_code=409)
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/pending/{enrollment_uuid}/revoke")
|
|
async def revoke_pending_installation(request: Request, enrollment_uuid: str):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
enr = _pending_or_404(session, enrollment_uuid)
|
|
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
|
was_code_active = bool(enr.install_code_hash)
|
|
enr.status = "REVOKED"
|
|
enr.download_token_hash = None
|
|
enr.enrollment_token_hash = None
|
|
enr.install_code_hash = None
|
|
enr.install_code_expires_at = None
|
|
if enr.agent_id:
|
|
agent = session.get(Agent, enr.agent_id)
|
|
if agent: agent.enabled = False
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid,
|
|
context={"had_agent": bool(enr.agent_id), "had_install_code": was_code_active})
|
|
if was_code_active:
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={})
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
|
finally: session.close()
|
|
|
|
@routes.post("/agents/pending/{enrollment_uuid}/code")
|
|
async def generate_install_code(request: Request, enrollment_uuid: str):
|
|
"""Generate (or rotate) the installation code shown to the operator and
|
|
typed into the universal EXE installer. Only the SHA-256 of the
|
|
normalized code is ever stored; the plaintext is returned in the HTML
|
|
once, to the SSO-authenticated operator who created the installation."""
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
enr = _pending_or_404(session, enrollment_uuid)
|
|
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
|
if enr.status in ("ENROLLED", "REVOKED"):
|
|
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
|
code = enrollment.generate_install_code()
|
|
now = datetime.now(timezone.utc)
|
|
enr.install_code_hash = enrollment.install_code_digest(code)
|
|
enr.install_code_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
|
|
enr.install_code_attempts = 0
|
|
enr.install_code_created_at = now
|
|
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_CREATED", target_type="agent_enrollment",
|
|
target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_enrollment_ttl_seconds, "ip": client_ip(request)})
|
|
session.commit()
|
|
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?code={code}", status_code=303)
|
|
finally: session.close()
|
|
|
|
@routes.get("/blockers", response_class=HTMLResponse)
|
|
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, reason_code: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None, scope: str = "active"):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
scope = scope if scope in ("active", "all") else "active"
|
|
status = status if status in ("OPEN", "RESOLVED", "ALL") else "OPEN"
|
|
filters = {"agent_id": agent_id, "product_code": product_code, "client": client, "installation": installation, "field": field, "blocker_type": blocker_type, "reason_code": reason_code, "status": status, "age": age, "search": search, "scope": scope}
|
|
result = list_blockers(session, page=page, page_size=50, **filters)
|
|
summary = blocker_filter_summary(session, **filters)
|
|
return render(request, "blockers.html", {"result": result, "summary": summary, "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "client_filter": client or "", "installation_filter": installation or "", "field_filter": field or "", "blocker_type_filter": blocker_type or "", "reason_code_filter": reason_code or "", "status_filter": status, "scope_filter": scope, "age_filter": age or "", "search": search or "", "query_base": query_base(**filters), "field_query_base": query_base(**{key: value for key, value in filters.items() if key != "field"})})
|
|
finally: session.close()
|
|
|
|
@routes.get("/blockers/{blocker_id}", response_class=HTMLResponse)
|
|
def blocker_page(request: Request, blocker_id: int):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
detail = blocker_detail(session, blocker_id)
|
|
if detail is None: return HTMLResponse("Blocker not found", status_code=404)
|
|
blocker = session.get(BlockedProduct, blocker_id)
|
|
installation = session.get(Installation, blocker.installation_id) if blocker.installation_id else None
|
|
tenant = session.get(Tenant, blocker.tenant_id)
|
|
company_key = _company_key(blocker, installation, tenant)
|
|
current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all() if installation else []
|
|
value_choices = sorted({value for values in ENUMS.values() for value in values})
|
|
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": value_choices, "error": None, "resolution_available": installation is not None, "resolution_values": {}})
|
|
finally: session.close()
|
|
|
|
@routes.post("/blockers/{blocker_id}/resolve-context")
|
|
async def resolve_blocker_with_context_route(request: Request, blocker_id: int):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
values = await form_values(request); web_session, admin = auth_data
|
|
require_csrf(web_session, values.get("csrf_token"))
|
|
payload = {key: values.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
|
|
try:
|
|
resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=request.client.host if request.client else None)
|
|
except BlockerResolutionUnavailable as error:
|
|
return HTMLResponse(str(error), status_code=409)
|
|
except LookupError:
|
|
return HTMLResponse("Blocker not found", status_code=404)
|
|
except ValueError as error:
|
|
detail = blocker_detail(session, blocker_id)
|
|
blocker = session.get(BlockedProduct, blocker_id)
|
|
installation = session.get(Installation, blocker.installation_id) if blocker and blocker.installation_id else None
|
|
tenant = session.get(Tenant, blocker.tenant_id) if blocker else None
|
|
company_key = _company_key(blocker, installation, tenant) if blocker else ""
|
|
current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all() if blocker and installation else []
|
|
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": sorted({value for values in ENUMS.values() for value in values}), "error": str(error), "resolution_available": installation is not None, "resolution_values": payload}, 422)
|
|
return RedirectResponse(f"/blockers/{blocker_id}", status_code=303)
|
|
finally:
|
|
session.close()
|
|
|
|
@routes.get("/jobs", response_class=HTMLResponse)
|
|
def jobs(request: Request, page: int = 1, status: str | None = None, search: str | None = None, kind: str | None = None, job_id: int | None = None, scope: str = "active"):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
scope = scope if scope in ("active", "all") else "active"
|
|
status = status if status in ("PENDING", "RUNNING", "COMPLETED", "FAILED") else None
|
|
result = list_jobs(session, page=page, page_size=50, status=status, search=search, kind=kind, job_id=job_id, scope=scope)
|
|
return render(request, "jobs.html", {"result": result, "status_counts": jobs_status_summary(session, scope=scope), "auth": auth_data, "status_filter": status or "", "scope_filter": scope, "search": search or "", "kind_filter": kind or "", "job_id_filter": job_id, "query_base": query_base(status=status, search=search, kind=kind, job_id=job_id, scope=scope)})
|
|
finally: session.close()
|
|
|
|
return routes
|