178 lines
12 KiB
Python
178 lines
12 KiB
Python
"""Server-rendered operator pages for the GTI Fiscal control panel."""
|
|
|
|
from datetime import datetime
|
|
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 select
|
|
|
|
from .config import settings
|
|
from .blocker_resolution import _company_key, resolve_blocker_with_context
|
|
from .context_keys import ALLOWED_KEYS, ENUMS
|
|
from .control_plane import blocker_detail, dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
|
|
from .human_auth import create_session, current_session, require_csrf, verify_password
|
|
from .panel_presentation import format_ui_datetime
|
|
from .models import AdminUser, Agent, BlockedProduct, FiscalContextEntry, Installation, Job, 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", ""), **(context or {})}
|
|
return templates.TemplateResponse(request=request, name=template, context=values, status_code=status_code)
|
|
|
|
def query_base(**values):
|
|
return urlencode({key: value for key, value in values.items() if value not in (None, "")})
|
|
|
|
@routes.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
return render(request, "login.html", {"error": None})
|
|
|
|
@routes.post("/login")
|
|
async def login(request: Request):
|
|
values = {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items}
|
|
with session_factory() as session:
|
|
admin = session.scalar(select(AdminUser).where(AdminUser.username == values.get("username", "").strip().lower()))
|
|
if not admin or not admin.is_active or not verify_password(values.get("password", ""), admin.password_hash):
|
|
return render(request, "login.html", {"error": "Credenciais inválidas"}, 401)
|
|
token, csrf = create_session(session, admin, request)
|
|
response = RedirectResponse("/", status_code=303)
|
|
response.set_cookie(settings.web_cookie_name, token, max_age=settings.web_session_seconds, httponly=True, secure=settings.web_cookie_secure, samesite="lax")
|
|
response.set_cookie("gti_csrf", csrf, max_age=settings.web_session_seconds, httponly=False, secure=settings.web_cookie_secure, samesite="lax")
|
|
return response
|
|
|
|
@routes.post("/logout")
|
|
async def logout(request: Request):
|
|
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"))
|
|
session.delete(web_session); session.commit()
|
|
response = RedirectResponse("/login", status_code=303)
|
|
response.delete_cookie(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(); return None, None, RedirectResponse("/login", 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"):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "query_base": query_base(search=search, health=health, sort=sort)})
|
|
finally: session.close()
|
|
|
|
@routes.get("/agents/{agent_id}", response_class=HTMLResponse)
|
|
def agent_detail(request: Request, agent_id: int, 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):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
result = list_installations(session, page=1, page_size=100)
|
|
detail = next((item for item in result["items"] if item["agent_id"] == agent_id), None)
|
|
if detail is None: return HTMLResponse("Agent not found", status_code=404)
|
|
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
|
events = list_panel_events(session, installation.id, page=event_page, page_size=25, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code)
|
|
for event in events:
|
|
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
|
|
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25), "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
|
|
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 HTMLResponse("confirmation required", status_code=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()
|
|
|
|
@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, status: str = "OPEN", age: str | None = None, search: str | None = None):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try:
|
|
result = list_blockers(session, page=page, page_size=50, client=client, installation=installation, field=field, blocker_type=blocker_type, status=status, age=age, search=search)
|
|
summary = dashboard_summary(session)
|
|
affected_clients = len({item["client"] for item in result["items"] if item.get("client")})
|
|
affected_products = len({item["product_code"] for item in result["items"] if item.get("product_code")})
|
|
return render(request, "blockers.html", {"result": result, "summary": summary, "affected_clients": affected_clients, "affected_products": affected_products, "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 "", "status_filter": status or "OPEN", "age_filter": age or "", "search": search or "", "query_base": query_base(client=client, installation=installation, field=field, blocker_type=blocker_type, status=status, age=age, search=search)})
|
|
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()
|
|
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})
|
|
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 LookupError:
|
|
return HTMLResponse("Blocker not found", status_code=404)
|
|
except ValueError as error:
|
|
detail = blocker_detail(session, blocker_id)
|
|
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": [], "context_keys": sorted(ALLOWED_KEYS), "value_choices": sorted({value for values in ENUMS.values() for value in values}), "error": str(error)}, 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):
|
|
session, auth_data, redirect = page_auth(request)
|
|
if redirect: return redirect
|
|
try: return render(request, "jobs.html", {"result": list_jobs(session, page=page, page_size=50, status=status, search=search, kind=kind), "auth": auth_data, "status_filter": status or "", "search": search or "", "kind_filter": kind or "", "query_base": query_base(status=status, search=search, kind=kind)})
|
|
finally: session.close()
|
|
|
|
return routes
|