from __future__ import annotations import logging import time from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.templating import Jinja2Templates from dependencies import get_unit_of_work, require_any_role from schemas.exports import ( ExportFormat, ProjectExportRequest, ScenarioExportRequest, ) from services.export_serializers import ( export_projects_to_excel, export_scenarios_to_excel, stream_projects_to_csv, stream_scenarios_to_csv, ) from services.unit_of_work import UnitOfWork from models.import_export_log import ImportExportLog from monitoring.metrics import observe_export logger = logging.getLogger(__name__) router = APIRouter(prefix="/exports", tags=["exports"]) @router.get( "/modal/{dataset}", response_model=None, response_class=HTMLResponse, include_in_schema=False, name="exports.modal", ) async def export_modal( dataset: str, request: Request, ) -> HTMLResponse: dataset = dataset.lower() if dataset not in {"projects", "scenarios"}: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Unknown dataset") submit_url = request.url_for( "export_projects" if dataset == "projects" else "export_scenarios" ) templates = Jinja2Templates(directory="templates") return templates.TemplateResponse( request, "exports/modal.html", { "dataset": dataset, "submit_url": submit_url, }, ) def _timestamp_suffix() -> str: return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") def _ensure_repository(repo, name: str): if repo is None: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"{name} repository unavailable") return repo def _record_export_audit( *, uow: UnitOfWork, dataset: str, status: str, export_format: ExportFormat, row_count: int, filename: str | None, ) -> None: try: if uow.session is None: return log = ImportExportLog( action="export", dataset=dataset, status=status, filename=filename, row_count=row_count, detail=f"format={export_format.value}", ) uow.session.add(log) uow.commit() except Exception: # best-effort auditing, do not break exports if uow.session is not None: uow.session.rollback() logger.exception( "export.audit.failed", extra={ "event": "export.audit", "dataset": dataset, "status": status, "format": export_format.value, }, ) @router.post( "/projects", status_code=status.HTTP_200_OK, response_class=StreamingResponse, dependencies=[Depends(require_any_role( "admin", "project_manager", "analyst"))], ) async def export_projects( request: ProjectExportRequest, uow: Annotated[UnitOfWork, Depends(get_unit_of_work)], ) -> Response: project_repo = _ensure_repository( getattr(uow, "projects", None), "Project") start = time.perf_counter() try: projects = project_repo.filtered_for_export(request.filters) except ValueError as exc: _record_export_audit( uow=uow, dataset="projects", status="failure", export_format=request.format, row_count=0, filename=None, ) logger.warning( "export.validation_failed", extra={ "event": "export", "dataset": "projects", "status": "validation_failed", "format": request.format.value, "error": str(exc), }, ) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc except Exception as exc: _record_export_audit( uow=uow, dataset="projects", status="failure", export_format=request.format, row_count=0, filename=None, ) logger.exception( "export.failed", extra={ "event": "export", "dataset": "projects", "status": "failure", "format": request.format.value, }, ) raise exc filename = f"projects-{_timestamp_suffix()}" if request.format == ExportFormat.CSV: stream = stream_projects_to_csv(projects) response = StreamingResponse(stream, media_type="text/csv") response.headers["Content-Disposition"] = f"attachment; filename={filename}.csv" _record_export_audit( uow=uow, dataset="projects", status="success", export_format=request.format, row_count=len(projects), filename=f"{filename}.csv", ) logger.info( "export", extra={ "event": "export", "dataset": "projects", "status": "success", "format": request.format.value, "row_count": len(projects), "filename": f"{filename}.csv", }, ) observe_export( dataset="projects", status="success", export_format=request.format.value, seconds=time.perf_counter() - start, ) return response data = export_projects_to_excel(projects) _record_export_audit( uow=uow, dataset="projects", status="success", export_format=request.format, row_count=len(projects), filename=f"{filename}.xlsx", ) logger.info( "export", extra={ "event": "export", "dataset": "projects", "status": "success", "format": request.format.value, "row_count": len(projects), "filename": f"{filename}.xlsx", }, ) observe_export( dataset="projects", status="success", export_format=request.format.value, seconds=time.perf_counter() - start, ) return StreamingResponse( iter([data]), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={ "Content-Disposition": f"attachment; filename={filename}.xlsx", }, ) @router.post( "/scenarios", status_code=status.HTTP_200_OK, response_class=StreamingResponse, dependencies=[Depends(require_any_role( "admin", "project_manager", "analyst"))], ) async def export_scenarios( request: ScenarioExportRequest, uow: Annotated[UnitOfWork, Depends(get_unit_of_work)], ) -> Response: scenario_repo = _ensure_repository( getattr(uow, "scenarios", None), "Scenario") start = time.perf_counter() try: scenarios = scenario_repo.filtered_for_export( request.filters, include_project=True) except ValueError as exc: _record_export_audit( uow=uow, dataset="scenarios", status="failure", export_format=request.format, row_count=0, filename=None, ) logger.warning( "export.validation_failed", extra={ "event": "export", "dataset": "scenarios", "status": "validation_failed", "format": request.format.value, "error": str(exc), }, ) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc except Exception as exc: _record_export_audit( uow=uow, dataset="scenarios", status="failure", export_format=request.format, row_count=0, filename=None, ) logger.exception( "export.failed", extra={ "event": "export", "dataset": "scenarios", "status": "failure", "format": request.format.value, }, ) raise exc filename = f"scenarios-{_timestamp_suffix()}" if request.format == ExportFormat.CSV: stream = stream_scenarios_to_csv(scenarios) response = StreamingResponse(stream, media_type="text/csv") response.headers["Content-Disposition"] = f"attachment; filename={filename}.csv" _record_export_audit( uow=uow, dataset="scenarios", status="success", export_format=request.format, row_count=len(scenarios), filename=f"{filename}.csv", ) logger.info( "export", extra={ "event": "export", "dataset": "scenarios", "status": "success", "format": request.format.value, "row_count": len(scenarios), "filename": f"{filename}.csv", }, ) observe_export( dataset="scenarios", status="success", export_format=request.format.value, seconds=time.perf_counter() - start, ) return response data = export_scenarios_to_excel(scenarios) _record_export_audit( uow=uow, dataset="scenarios", status="success", export_format=request.format, row_count=len(scenarios), filename=f"{filename}.xlsx", ) logger.info( "export", extra={ "event": "export", "dataset": "scenarios", "status": "success", "format": request.format.value, "row_count": len(scenarios), "filename": f"{filename}.xlsx", }, ) observe_export( dataset="scenarios", status="success", export_format=request.format.value, seconds=time.perf_counter() - start, ) return StreamingResponse( iter([data]), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={ "Content-Disposition": f"attachment; filename={filename}.xlsx", }, )