2026-07-21 10:29:31 +02:00

642 lines
14 KiB
Python

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(),
},
)