from __future__ import annotations import asyncio import logging from datetime import datetime, timezone from typing import Any, Literal import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from opentelemetry import propagate, trace from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker, Session from app.core.audit import ExportRecord, append_audit, current_span_context from app.core.config import settings from app.core.executor import get_executor from app.core.export import to_pdf_bytes from app.core.security import FrontendPrincipal, require_frontend_principal from app.domain.wwi import analytics LOGGER = logging.getLogger(__name__) tracer = trace.get_tracer("otel-bi.routers.wwi") router = APIRouter(prefix="/api/wwi", tags=["wwi"]) _XLSX_MEDIA = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" _PDF_MEDIA = "application/pdf" class WhatIfRequest(BaseModel): stock_item_key: int = Field(..., ge=1) demand_multiplier: float = Field(default=1.0, ge=0.1, le=5.0) def _trace_headers() -> dict[str, str]: ctx = trace.get_current_span().get_span_context() if not ctx.is_valid: return {} return {"x-trace-id": f"{ctx.trace_id:032x}", "x-span-id": f"{ctx.span_id:016x}"} def _propagation_headers() -> dict[str, str]: headers: dict[str, str] = {} propagate.inject(headers) return headers async def _get(client: httpx.AsyncClient, path: str, params: dict | None = None) -> Any: try: r = await client.get(path, params=params, headers=_propagation_headers()) r.raise_for_status() return r.json() except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502, detail=f"Analytics service error: {exc.response.status_code}") except httpx.RequestError as exc: raise HTTPException(status_code=503, detail=f"Analytics service unavailable: {exc}") async def _post(client: httpx.AsyncClient, path: str, json: dict) -> Any: try: r = await client.post(path, json=json, headers=_propagation_headers()) r.raise_for_status() return r.json() except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502 if exc.response.status_code != 404 else 404, detail=f"Analytics service error: {exc.response.status_code}") except httpx.RequestError as exc: raise HTTPException(status_code=503, detail=f"Analytics service unavailable: {exc}") def _record_export( pg_factory: sessionmaker[Session], domain: str, source_view: str, fmt: str, filters: dict, row_count: int, file_size_bytes: int, actor_id: str, trace_id: str | None, span_id: str | None, ) -> None: try: with pg_factory() as session: session.add(ExportRecord( domain=domain, service="otel-bi-backend", source_view=source_view, format=fmt, filters_applied=filters, row_count=row_count, file_size_bytes=file_size_bytes, actor_id=actor_id, trace_id=trace_id, span_id=span_id, )) session.commit() except Exception as exc: # noqa: BLE001 LOGGER.warning("Failed to record export metadata: %s", exc) append_audit( pg_factory, action="export.created", actor_type="user", actor_id=actor_id, domain=domain, service="otel-bi-backend", entity_type=source_view, payload={"format": fmt, "row_count": row_count, "file_size_bytes": file_size_bytes, **filters}, ) async def _proxy_xlsx( client: httpx.AsyncClient, go_path: str, params: dict, filename_stem: str, domain: str, source_view: str, filters: dict, actor_id: str, pg_factory: sessionmaker[Session], ) -> Response: """Fetch XLSX bytes from Go, write ExportRecord, return response.""" try: r = await client.get(go_path, params=params, headers=_propagation_headers()) r.raise_for_status() except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502, detail=f"Analytics service error: {exc.response.status_code}") except httpx.RequestError as exc: raise HTTPException(status_code=503, detail=f"Analytics service unavailable: {exc}") content = r.content row_count = int(r.headers.get("X-Row-Count", "0")) today = datetime.now(timezone.utc).strftime("%Y%m%d") filename = f"{filename_stem}_{today}.xlsx" trace_id, span_id = current_span_context() await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _record_export(pg_factory, domain, source_view, "xlsx", filters, row_count, len(content), actor_id, trace_id, span_id), ) return Response( content=content, media_type=_XLSX_MEDIA, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) def _make_pdf( data: list[dict], filename_stem: str, pdf_title: str, domain: str, source_view: str, filters: dict, actor_id: str, pg_factory: sessionmaker[Session], ) -> Response: with tracer.start_as_current_span(f"export.{domain}.{source_view}") as span: span.set_attribute("export.format", "pdf") span.set_attribute("export.row_count", len(data)) content = to_pdf_bytes(data, title=pdf_title) span.set_attribute("export.file_size_bytes", len(content)) today = datetime.now(timezone.utc).strftime("%Y%m%d") filename = f"{filename_stem}_{today}.pdf" trace_id, span_id = current_span_context() _record_export(pg_factory, domain, source_view, "pdf", filters, len(data), len(content), actor_id, trace_id, span_id) return Response( content=content, media_type=_PDF_MEDIA, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) # --------------------------------------------------------------------------- # KPIs # --------------------------------------------------------------------------- @router.get("/sales/kpis") async def wwi_sales_kpis( response: Response, request: Request, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> dict: response.headers.update(_trace_headers()) return await _get(request.app.state.analytics_client, "/wwi/sales/kpis") # --------------------------------------------------------------------------- # Stock & reorder # --------------------------------------------------------------------------- @router.get("/stock/recommendations") async def wwi_reorder_recommendations( response: Response, request: Request, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) client = request.app.state.analytics_client pg_factory = request.app.state.pg_factory data = await _get(client, "/wwi/stock/recommendations") loop = asyncio.get_running_loop() await loop.run_in_executor( get_executor(), lambda: ( analytics.generate_stock_events(pg_factory, data), analytics.persist_reorder_recommendations(pg_factory, data, "api.stock.recommendations"), ), ) return data # --------------------------------------------------------------------------- # Supplier scores # --------------------------------------------------------------------------- @router.get("/suppliers/scores") async def wwi_supplier_scores( response: Response, request: Request, top_n: int = Query(default=settings.ranking_default_top_n, ge=3, le=100), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) client = request.app.state.analytics_client pg_factory = request.app.state.pg_factory data = await _get(client, "/wwi/suppliers/scores", {"top_n": top_n}) loop = asyncio.get_running_loop() await loop.run_in_executor( get_executor(), lambda: analytics.persist_supplier_scores(pg_factory, data, top_n, "api.suppliers.scores"), ) return data # --------------------------------------------------------------------------- # Business events # --------------------------------------------------------------------------- @router.get("/events") async def wwi_business_events( response: Response, request: Request, limit: int = Query(default=100, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) pg_factory = request.app.state.pg_factory return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: analytics.get_business_events(pg_factory, limit=limit) ) # --------------------------------------------------------------------------- # What-if scenarios # --------------------------------------------------------------------------- @router.post("/scenarios") async def wwi_create_scenario( body: WhatIfRequest, response: Response, request: Request, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> dict: response.headers.update(_trace_headers()) client = request.app.state.analytics_client pg_factory = request.app.state.pg_factory result = await _post(client, "/wwi/scenarios", { "stock_item_key": body.stock_item_key, "demand_multiplier": body.demand_multiplier, }) loop = asyncio.get_running_loop() await loop.run_in_executor( get_executor(), lambda: analytics.persist_whatif_scenario(pg_factory, result), ) return result @router.get("/scenarios") async def wwi_list_scenarios( response: Response, request: Request, limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) pg_factory = request.app.state.pg_factory return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: analytics.list_whatif_scenarios(pg_factory, limit=limit) ) # --------------------------------------------------------------------------- # Stored records # --------------------------------------------------------------------------- @router.get("/records/reorder-recommendations") async def wwi_records_reorder( response: Response, request: Request, limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) pg_factory = request.app.state.pg_factory return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: analytics.list_reorder_recommendations(pg_factory, limit=limit) ) @router.get("/records/supplier-scores") async def wwi_records_supplier_scores( response: Response, request: Request, limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) pg_factory = request.app.state.pg_factory return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: analytics.list_supplier_scores(pg_factory, limit=limit) ) # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- @router.get("/export/stock-recommendations") async def export_wwi_stock_recommendations( request: Request, format: Literal["xlsx", "pdf"] = Query(default="xlsx"), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> Response: client = request.app.state.analytics_client pg_factory = request.app.state.pg_factory actor_id = principal.subject if format == "xlsx": return await _proxy_xlsx(client, "/wwi/export/stock-recommendations", {}, "wwi_stock_recommendations", "wwi", "stock-recommendations", {}, actor_id, pg_factory) data = await _get(client, "/wwi/stock/recommendations") return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _make_pdf(data, "wwi_stock_recommendations", "WideWorldImporters — Stock Reorder Recommendations", "wwi", "stock-recommendations", {}, actor_id, pg_factory), ) @router.get("/export/supplier-scores") async def export_wwi_supplier_scores( request: Request, format: Literal["xlsx", "pdf"] = Query(default="xlsx"), top_n: int = Query(default=settings.ranking_default_top_n, ge=3, le=100), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> Response: client = request.app.state.analytics_client pg_factory = request.app.state.pg_factory actor_id = principal.subject filters = {"top_n": top_n} if format == "xlsx": return await _proxy_xlsx(client, "/wwi/export/supplier-scores", filters, "wwi_supplier_scores", "wwi", "supplier-scores", filters, actor_id, pg_factory) data = await _get(client, "/wwi/suppliers/scores", filters) return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _make_pdf(data, "wwi_supplier_scores", "WideWorldImporters — Supplier Reliability Scores", "wwi", "supplier-scores", filters, actor_id, pg_factory), ) @router.get("/export/business-events") async def export_wwi_business_events( request: Request, format: Literal["xlsx", "pdf"] = Query(default="xlsx"), limit: int = Query(default=100, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> Response: pg_factory = request.app.state.pg_factory actor_id = principal.subject filters = {"limit": limit} data = await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: analytics.get_business_events(pg_factory, limit=limit) ) return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _make_pdf(data, "wwi_business_events", "WideWorldImporters — Business Events", "wwi", "business-events", filters, actor_id, pg_factory), ) # --------------------------------------------------------------------------- # Job triggers # --------------------------------------------------------------------------- @router.post("/jobs/{job_name}/trigger") async def trigger_wwi_job( job_name: str, response: Response, request: Request, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> dict: response.headers.update(_trace_headers()) return await _post(request.app.state.analytics_client, f"/scheduler/wwi/{job_name}/trigger", {}) @router.get("/jobs") async def wwi_job_history( response: Response, request: Request, limit: int = Query(default=50, ge=1, le=200), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) pg_factory = request.app.state.pg_factory return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _list_jobs(pg_factory, "wwi", limit) ) def _list_jobs(pg_factory, domain: str, limit: int) -> list[dict]: from app.core.audit import JobExecution with pg_factory() as session: rows = ( session.query(JobExecution) .filter_by(domain=domain) .order_by(JobExecution.started_at.desc()) .limit(limit) .all() ) return [ { "id": r.id, "job_name": r.job_name, "domain": r.domain, "status": r.status, "started_at": r.started_at.isoformat(), "completed_at": r.completed_at.isoformat() if r.completed_at else None, "duration_ms": r.duration_ms, "records_processed": r.records_processed, "error_message": r.error_message, "trace_id": r.trace_id, } for r in rows ]