import os import re from datetime import datetime from decimal import Decimal from typing import Any import psycopg from psycopg.rows import dict_row from fastapi import FastAPI, HTTPException, Query from fastapi.responses import HTMLResponse, Response from fastapi.encoders import jsonable_encoder from fastapi.templating import Jinja2Templates from starlette.requests import Request from weasyprint import HTML DATABASE_URL = os.getenv("DATABASE_URL") REPORT_TITLE = os.getenv("REPORT_TITLE", "MESAVAULT Edge-OEE UCEPSA") if not DATABASE_URL: raise RuntimeError("DATABASE_URL environment variable is required") app = FastAPI(title="MESAVAULT Order Report API") templates = Jinja2Templates(directory="/app/app/templates") def safe_filename(value: str) -> str: cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") return cleaned or "order-report" def fmt_dt(value: Any) -> str: if value is None: return "" if isinstance(value, datetime): return value.strftime("%d/%m/%Y %H:%M:%S") text = str(value) text = re.sub(r"\.\d+", "", text) text = text.replace("+02:00", "").replace("+01:00", "") return text def fmt_num(value: Any, decimals: int = 2) -> str: if value is None: return "" try: number = float(value) return f"{number:.{decimals}f}" except (ValueError, TypeError): return str(value) templates.env.filters["dt"] = fmt_dt templates.env.filters["num"] = fmt_num def fetch_one(query: str, params: tuple[Any, ...]) -> dict[str, Any] | None: with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn: with conn.cursor() as cur: cur.execute(query, params) return cur.fetchone() def fetch_all(query: str, params: tuple[Any, ...]) -> list[dict[str, Any]]: with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn: with conn.cursor() as cur: cur.execute(query, params) return list(cur.fetchall()) def build_report_context(order_ref: str) -> dict[str, Any]: report = fetch_one( """ SELECT * FROM mv_reports_ucepsa_demo.order_execution_report_full_v1 WHERE odoo_order_ref = %s """, (order_ref,), ) if report is None: raise HTTPException(status_code=404, detail=f"Order not found: {order_ref}") stops = fetch_all( """ SELECT stop_id, stop_started_at, stop_ended_at, attributed_duration_min, cause_code, operator_note, classification_status, attributed_cost_eur, stop_quality_status FROM mv_reports_ucepsa_demo.order_execution_stops_v1 WHERE odoo_order_ref = %s ORDER BY attributed_started_at """, (order_ref,), ) timeline = fetch_all( """ SELECT event_ts, event_type, event_label, severity, cause_code, duration_min, cost_eur, description FROM mv_reports_ucepsa_demo.order_execution_timeline_v1 WHERE odoo_order_ref = %s ORDER BY event_ts, event_type """, (order_ref,), ) return { "title": REPORT_TITLE, "report": report, "stops": stops, "timeline": timeline, } @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @app.get("/api/report") def api_report(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")): return jsonable_encoder(build_report_context(order_ref)) @app.get("/report.html", response_class=HTMLResponse) def report_html( request: Request, order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025"), ): context = build_report_context(order_ref) context["request"] = request return templates.TemplateResponse( "order_report.html", context, ) @app.get("/report.pdf") def report_pdf(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")): context = build_report_context(order_ref) template = templates.env.get_template("order_report.html") html_content = template.render(**context) pdf_bytes = HTML( string=html_content, base_url="/app/app", ).write_pdf() filename = f"mesavault-order-report-{safe_filename(order_ref)}.pdf" return Response( content=pdf_bytes, media_type="application/pdf", headers={ "Content-Disposition": f'inline; filename="{filename}"' }, )