642 lines
14 KiB
Python
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(),
|
|
},
|
|
)
|