"""Dashboard Jinja2 + HTMX (server-rendered, zero build). Schelet cu stari explicite: empty (coada goala), banner alerta blocate, worker viu/mort, ultimul login RAR. Editor mapari + browser nomenclator + export CSV + stare "RAR indisponibil" = de adaugat (plan.md sect. 4 + design-review). """ from __future__ import annotations from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from .. import __version__ from ..config import get_settings from ..db import get_connection, read_heartbeat from ..mapping import load_nomenclator, pending_unmapped, reresolve_account, save_mapping router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) _BLOCKED = ("error", "needs_data", "needs_mapping") def _status_counts(conn) -> dict[str, int]: rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall() return {r["status"]: int(r["n"]) for r in rows} def _worker_alive(hb) -> bool: if hb is None or not hb["last_beat"]: return False try: last = datetime.fromisoformat(hb["last_beat"]) except ValueError: return False age = (datetime.now(timezone.utc) - last).total_seconds() return age <= get_settings().worker_heartbeat_stale_s def _rar_state(hb, worker_alive: bool) -> str: """Eticheta de disponibilitate RAR, derivata din ultimul login reusit. Nu interogam RAR live aici (dashboard-ul degradeaza la ultima stare cunoscuta a cozii). JWT TTL = 30h: un login mai vechi de atat inseamna ca nu mai stim sigur ca RAR raspunde -> "indisponibil?". Fara niciun login -> necunoscut. """ if not worker_alive: return "necunoscut (worker oprit)" last = hb["last_rar_login_ok"] if hb else None if not last: return "fara login reusit inca" try: age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds() except (ValueError, TypeError): return "necunoscut" return "indisponibil?" if age > 108000 else "ok" @router.get("/", response_class=HTMLResponse) def dashboard(request: Request) -> HTMLResponse: conn = get_connection() try: counts = _status_counts(conn) hb = read_heartbeat(conn) blocked = sum(counts.get(s, 0) for s in _BLOCKED) worker_alive = _worker_alive(hb) ctx = { "request": request, "rar_env": get_settings().rar_env, "version": __version__, "counts": counts, "blocked": blocked, "worker_alive": worker_alive, "last_login": hb["last_rar_login_ok"] if hb else None, "rar_state": _rar_state(hb, worker_alive), } return templates.TemplateResponse("dashboard.html", ctx) finally: conn.close() @router.get("/_fragments/nomenclator", response_class=HTMLResponse) def fragment_nomenclator(request: Request) -> HTMLResponse: """Browser nomenclator RAR (cache local upsert-at de worker la fiecare login).""" conn = get_connection() try: rows = conn.execute( "SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie" ).fetchall() return templates.TemplateResponse( "_nomenclator.html", {"request": request, "rows": rows} ) finally: conn.close() @router.get("/_fragments/banner", response_class=HTMLResponse) def fragment_banner(request: Request) -> HTMLResponse: conn = get_connection() try: counts = _status_counts(conn) blocked = sum(counts.get(s, 0) for s in _BLOCKED) return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked}) finally: conn.close() @router.get("/_fragments/submissions", response_class=HTMLResponse) def fragment_submissions(request: Request) -> HTMLResponse: conn = get_connection() try: rows = conn.execute( "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at " "FROM submissions ORDER BY id DESC LIMIT 100" ).fetchall() return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows}) finally: conn.close() def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse: return templates.TemplateResponse( "_mapari.html", { "request": request, "pending": pending_unmapped(conn), "nomenclator": load_nomenclator(conn), "message": message, }, ) @router.get("/_fragments/mapari", response_class=HTMLResponse) def fragment_mapari(request: Request) -> HTMLResponse: """Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.""" conn = get_connection() try: return _render_mapari(request, conn) finally: conn.close() @router.post("/mapari", response_class=HTMLResponse) def post_mapare( request: Request, cod_op_service: str = Form(...), cod_prestatie: str = Form(...), account_id: int | None = Form(None), auto_send: bool = Form(False), ) -> HTMLResponse: """Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul.""" conn = get_connection() try: cod = cod_prestatie.strip().upper() exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone() if not exists: return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}") save_mapping(conn, account_id, cod_op_service, cod, auto_send) stats = reresolve_account(conn, account_id) msg = ( f"Mapat {cod_op_service.strip()} -> {cod}. " f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, " f"{stats['still_blocked']} inca nemapate." ) return _render_mapari(request, conn, message=msg) finally: conn.close()