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 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.aw import analytics LOGGER = logging.getLogger(__name__) tracer = trace.get_tracer("otel-bi.routers.aw") router = APIRouter(prefix="/api/aw", tags=["aw"]) _XLSX_MEDIA = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" _PDF_MEDIA = "application/pdf" 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}"'}, ) # --------------------------------------------------------------------------- # Sales # --------------------------------------------------------------------------- @router.get("/sales/kpis") async def aw_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, "/aw/sales/kpis") @router.get("/sales/history") async def aw_sales_history( response: Response, request: Request, days_back: int = Query(default=settings.default_history_days, ge=30, le=1460), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: response.headers.update(_trace_headers()) return await _get(request.app.state.analytics_client, "/aw/sales/history", {"days_back": days_back}) @router.get("/sales/forecast") async def aw_sales_forecast( response: Response, request: Request, horizon_days: int = Query(default=settings.forecast_horizon_days, ge=7, le=180), 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, "/aw/sales/forecast", {"horizon_days": horizon_days}) loop = asyncio.get_running_loop() await loop.run_in_executor( get_executor(), lambda: analytics.persist_forecast(pg_factory, data, horizon_days, "api.sales.forecast"), ) return data # --------------------------------------------------------------------------- # Rep scores & product demand # --------------------------------------------------------------------------- @router.get("/reps/scores") async def aw_rep_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, "/aw/reps/scores", {"top_n": top_n}) loop = asyncio.get_running_loop() await loop.run_in_executor( get_executor(), lambda: analytics.persist_rep_scores(pg_factory, data, top_n, "api.reps.scores"), ) return data @router.get("/products/demand") async def aw_product_demand( 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, "/aw/products/demand", {"top_n": top_n}) loop = asyncio.get_running_loop() await loop.run_in_executor( get_executor(), lambda: analytics.persist_product_demand(pg_factory, data, top_n, "api.products.demand"), ) return data # --------------------------------------------------------------------------- # Anomaly detection # --------------------------------------------------------------------------- @router.get("/anomalies") async def aw_anomalies( 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, "/aw/anomalies") loop = asyncio.get_running_loop() await loop.run_in_executor( get_executor(), lambda: analytics.persist_anomaly_run(pg_factory, data, "api.aw.anomalies"), ) return data # --------------------------------------------------------------------------- # Stored records # --------------------------------------------------------------------------- @router.get("/records/forecasts") async def aw_records_forecasts( 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_forecasts(pg_factory, limit=limit) ) @router.get("/records/rep-scores") async def aw_records_rep_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_rep_scores(pg_factory, limit=limit) ) @router.get("/records/product-demand") async def aw_records_product_demand( 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_product_demand(pg_factory, limit=limit) ) # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- @router.get("/export/sales-history") async def export_aw_sales_history( request: Request, format: Literal["xlsx", "pdf"] = Query(default="xlsx"), days_back: int = Query(default=settings.default_history_days, ge=30, le=1460), 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 = {"days_back": days_back} if format == "xlsx": return await _proxy_xlsx(client, "/aw/export/sales-history", filters, "aw_sales_history", "aw", "sales-history", filters, actor_id, pg_factory) data = await _get(client, "/aw/sales/history", filters) return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _make_pdf(data, "aw_sales_history", "AdventureWorks — Sales History", "aw", "sales-history", filters, actor_id, pg_factory), ) @router.get("/export/sales-forecast") async def export_aw_sales_forecast( request: Request, format: Literal["xlsx", "pdf"] = Query(default="xlsx"), horizon_days: int = Query(default=settings.forecast_horizon_days, ge=7, le=180), 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 = {"horizon_days": horizon_days} if format == "xlsx": return await _proxy_xlsx(client, "/aw/export/sales-forecast", filters, "aw_sales_forecast", "aw", "sales-forecast", filters, actor_id, pg_factory) data = await _get(client, "/aw/sales/forecast", filters) return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _make_pdf(data, "aw_sales_forecast", "AdventureWorks — Sales Forecast", "aw", "sales-forecast", filters, actor_id, pg_factory), ) @router.get("/export/rep-scores") async def export_aw_rep_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, "/aw/export/rep-scores", filters, "aw_rep_scores", "aw", "rep-scores", filters, actor_id, pg_factory) data = await _get(client, "/aw/reps/scores", filters) return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _make_pdf(data, "aw_rep_scores", "AdventureWorks — Sales Rep Performance", "aw", "rep-scores", filters, actor_id, pg_factory), ) @router.get("/export/product-demand") async def export_aw_product_demand( 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, "/aw/export/product-demand", filters, "aw_product_demand", "aw", "product-demand", filters, actor_id, pg_factory) data = await _get(client, "/aw/products/demand", filters) return await asyncio.get_running_loop().run_in_executor( get_executor(), lambda: _make_pdf(data, "aw_product_demand", "AdventureWorks — Product Demand Scores", "aw", "product-demand", filters, actor_id, pg_factory), ) # --------------------------------------------------------------------------- # Job triggers # --------------------------------------------------------------------------- @router.post("/jobs/{job_name}/trigger") async def trigger_aw_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/aw/{job_name}/trigger", {}) @router.get("/jobs") async def aw_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, "aw", 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 ]