from __future__ import annotations from pathlib import Path from typing import Any from urllib.parse import quote from fastapi import ( FastAPI, Form, HTTPException, Request, status, ) from fastapi.responses import ( HTMLResponse, RedirectResponse, ) from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from psycopg2.extras import RealDictCursor from .auth import ( SESSION_COOKIE, authenticate, build_session_token, can_review_owner, can_view_owner, read_current_user, require_user, verify_csrf, ) from .db import get_connection from .review_service import ( ReviewError, ReviewInput, apply_review, ) from .settings import get_settings BASE_DIR = Path(__file__).resolve().parent app = FastAPI( title="MESAVAULT Governance Review UI", version="0.3.14", ) app.mount( "/static", StaticFiles( directory=BASE_DIR / "static" ), name="static", ) templates = Jinja2Templates( directory=BASE_DIR / "templates" ) def format_datetime(value: Any) -> str: if value is None: return "—" try: return value.strftime( "%d/%m/%Y %H:%M:%S" ) except AttributeError: return str(value) templates.env.filters[ "datetime_es" ] = format_datetime def visible_queue( user, ) -> list[dict[str, Any]]: with get_connection() as connection: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_governance_ui_queue_v1 ORDER BY agenda_rank """ ) rows = [ dict(row) for row in cursor.fetchall() ] return [ row for row in rows if can_view_owner( user, str(row["review_owner"]), ) ] def recent_actions( limit: int = 50, ) -> list[dict[str, Any]]: with get_connection() as connection: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_governance_ui_recent_actions_v1 ORDER BY created_at DESC, ui_action_id DESC LIMIT %s """, (limit,), ) return [ dict(row) for row in cursor.fetchall() ] def load_decision( decision_group_key: str, ) -> tuple[ dict[str, Any], list[dict[str, Any]], ]: with get_connection() as connection: with connection.cursor( cursor_factory=RealDictCursor, ) as cursor: cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_governance_decision_evidence_pack_v1 WHERE decision_group_key = %s """, (decision_group_key,), ) decision = cursor.fetchone() if not decision: raise HTTPException( status_code=404, detail="Decisión no encontrada.", ) cursor.execute( """ SELECT * FROM mv_reports_ucepsa_prod .li_context_governance_ui_case_timeline_v1 WHERE decision_group_key = %s ORDER BY first_seen_at, case_id """, (decision_group_key,), ) cases = [ dict(row) for row in cursor.fetchall() ] return dict(decision), cases def answer_options( decision_family: str, ) -> list[tuple[str, str]]: if decision_family == "UNCONFIRMED_ACTIVITY": return [ ( "AUTHORIZED_LEGACY_PRODUCTION", "Producción con el sistema antiguo", ), ( "ODOO_START_OMITTED", "Producción real, pero se olvidó iniciar Odoo", ), ( "TEST_OR_SETUP", "Prueba, ajuste o preparación", ), ( "RESIDUAL_MATERIAL", "Material residual o vaciado de máquina", ), ( "MAINTENANCE", "Mantenimiento", ), ( "NOT_PRODUCTION", "No era producción", ), ( "OTHER", "Otro", ), ] if decision_family == "ODOO_ORDER_START_CONFLICT": return [ ( "ODOO_DOUBLE_START_ONE_VALID", "Una orden era válida y la otra quedó abierta por error", ), ( "ODOO_DOUBLE_START_BOTH_INVALID", "Ninguna de las órdenes era válida", ), ( "ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED", "Eran órdenes consecutivas y la anterior no se cerró", ), ( "DATA_ISSUE", "Incidencia de datos o configuración", ), ( "OTHER", "Otro", ), ] return [ ( "DATA_ISSUE", "Incidencia de datos", ), ( "OTHER", "Otro", ), ] @app.get( "/health", response_class=HTMLResponse, ) def health() -> str: with get_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT 1") cursor.fetchone() return "ok" @app.get( "/login", response_class=HTMLResponse, ) def login_page( request: Request, ): if read_current_user(request): return RedirectResponse( "/", status_code=status.HTTP_303_SEE_OTHER, ) return templates.TemplateResponse( request=request, name="login.html", context={ "settings": get_settings(), "error": None, }, ) @app.post( "/login", response_class=HTMLResponse, ) def login_submit( request: Request, username: str = Form(...), password: str = Form(...), ): user = authenticate( username.strip(), password, ) if user is None: return templates.TemplateResponse( request=request, name="login.html", status_code=401, context={ "settings": get_settings(), "error": "Usuario o contraseña incorrectos.", }, ) response = RedirectResponse( "/", status_code=status.HTTP_303_SEE_OTHER, ) response.set_cookie( SESSION_COOKIE, build_session_token(user), max_age=get_settings().session_max_age_s, httponly=True, secure=get_settings().cookie_secure, samesite="lax", path="/", ) return response @app.post("/logout") def logout( request: Request, csrf_token: str = Form(...), ): user = require_user(request) verify_csrf( user, csrf_token, ) response = RedirectResponse( "/login", status_code=status.HTTP_303_SEE_OTHER, ) response.delete_cookie( SESSION_COOKIE, path="/", ) return response @app.get( "/", response_class=HTMLResponse, ) def queue_page( request: Request, message: str | None = None, ): user = read_current_user(request) if user is None: return RedirectResponse( "/login", status_code=status.HTTP_303_SEE_OTHER, ) return templates.TemplateResponse( request=request, name="queue.html", context={ "settings": get_settings(), "user": user, "rows": visible_queue(user), "message": message, }, ) @app.get( "/decisions/{decision_group_key:path}", response_class=HTMLResponse, ) def decision_page( request: Request, decision_group_key: str, error: str | None = None, ): user = read_current_user(request) if user is None: return RedirectResponse( "/login", status_code=status.HTTP_303_SEE_OTHER, ) decision, cases = load_decision( decision_group_key ) if not can_view_owner( user, str(decision["review_owner"]), ): raise HTTPException( status_code=403, detail="No tiene acceso a esta decisión.", ) pending_cases = [ case for case in cases if case["case_review_status"] == "PENDING" ] order_pairs = sorted({ ( str(item["production_order"]), int(item["odoo_workorder_id"]), ) for item in ( decision.get( "episode_evidence" ) or [] ) if item.get( "production_order" ) and item.get( "odoo_workorder_id" ) is not None }) return templates.TemplateResponse( request=request, name="decision.html", context={ "settings": get_settings(), "user": user, "decision": decision, "cases": cases, "pending_cases": pending_cases, "answer_options": answer_options( str( decision[ "decision_family" ] ) ), "order_pairs": order_pairs, "can_review": ( get_settings().write_enabled and can_review_owner( user, str(decision["review_owner"]), ) ), "write_enabled": get_settings().write_enabled, "error": error, }, ) @app.post( "/decisions/{decision_group_key:path}/review" ) def review_decision( request: Request, decision_group_key: str, csrf_token: str = Form(...), case_ids: list[int] = Form(default=[]), answer_code: str = Form(...), notes: str = Form(""), selected_order: str = Form(""), corrected_in_odoo: bool = Form(False), create_contexts: bool = Form(False), confirmation: bool = Form(False), ): user = require_user(request) verify_csrf( user, csrf_token, ) if not get_settings().write_enabled: raise HTTPException( status_code=503, detail="La interfaz está en modo solo lectura.", ) decision, _ = load_decision( decision_group_key ) if not can_review_owner( user, str(decision["review_owner"]), ): raise HTTPException( status_code=403, detail="No puede resolver esta decisión.", ) selected_order_ref = None selected_workorder_id = None if selected_order: try: selected_order_ref, raw_id = ( selected_order.split( "|", 1, ) ) selected_workorder_id = int( raw_id ) except ( ValueError, TypeError, ): target = ( "/decisions/" + quote( decision_group_key, safe="", ) + "?error=" + quote( "Orden seleccionada no válida." ) ) return RedirectResponse( target, status_code=303, ) review = ReviewInput( decision_group_key= decision_group_key, selected_case_ids=tuple( sorted( set( int(case_id) for case_id in case_ids ) ) ), answer_code=answer_code, notes=notes.strip(), selected_order_ref= selected_order_ref, selected_workorder_id= selected_workorder_id, corrected_in_odoo= corrected_in_odoo, create_contexts= create_contexts, confirmation= confirmation, ) try: result = apply_review( review=review, user=user, source_ip=( request.client.host if request.client else None ), user_agent=request.headers.get( "user-agent" ), ) except ReviewError as exc: target = ( "/decisions/" + quote( decision_group_key, safe="", ) + "?error=" + quote(str(exc)) ) return RedirectResponse( target, status_code=303, ) message = ( "Revisión registrada. " f"Acción UI {result['ui_action_id']}." ) if result[ "created_context_session_ids" ]: message += ( " Contextos creados: " + ", ".join( str(value) for value in result[ "created_context_session_ids" ] ) + "." ) return RedirectResponse( "/?message=" + quote(message), status_code=303, ) @app.get( "/history", response_class=HTMLResponse, ) def history_page( request: Request, ): user = read_current_user(request) if user is None: return RedirectResponse( "/login", status_code=303, ) return templates.TemplateResponse( request=request, name="history.html", context={ "settings": get_settings(), "user": user, "actions": recent_actions(), }, )