diff --git a/dependencies.py b/dependencies.py index b91c05d..100504d 100644 --- a/dependencies.py +++ b/dependencies.py @@ -21,6 +21,7 @@ from services.session import ( extract_session_tokens, ) from services.unit_of_work import UnitOfWork +from services.importers import ImportIngestionService def get_unit_of_work() -> Generator[UnitOfWork, None, None]: @@ -30,6 +31,15 @@ def get_unit_of_work() -> Generator[UnitOfWork, None, None]: yield uow +_IMPORT_INGESTION_SERVICE = ImportIngestionService(lambda: UnitOfWork()) + + +def get_import_ingestion_service() -> ImportIngestionService: + """Provide singleton import ingestion service.""" + + return _IMPORT_INGESTION_SERVICE + + def get_application_settings() -> Settings: """Provide cached application settings instance.""" diff --git a/main.py b/main.py index 7c28ebf..999b17c 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from models import ( ) from routes.auth import router as auth_router from routes.dashboard import router as dashboard_router +from routes.imports import router as imports_router from routes.projects import router as projects_router from routes.scenarios import router as scenarios_router from services.bootstrap import bootstrap_admin @@ -61,6 +62,7 @@ async def ensure_admin_bootstrap() -> None: app.include_router(dashboard_router) app.include_router(auth_router) +app.include_router(imports_router) app.include_router(projects_router) app.include_router(scenarios_router) diff --git a/routes/imports.py b/routes/imports.py new file mode 100644 index 0000000..40cffac --- /dev/null +++ b/routes/imports.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from io import BytesIO + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status + +from dependencies import get_import_ingestion_service, require_roles +from models import User +from schemas.imports import ( + ImportCommitRequest, + ProjectImportCommitResponse, + ProjectImportPreviewResponse, + ScenarioImportCommitResponse, + ScenarioImportPreviewResponse, +) +from services.importers import ImportIngestionService, UnsupportedImportFormat + +router = APIRouter(prefix="/imports", tags=["Imports"]) + +MANAGE_ROLES = ("project_manager", "admin") + + +async def _read_upload_file(upload: UploadFile) -> BytesIO: + content = await upload.read() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Uploaded file is empty.", + ) + return BytesIO(content) + + +@router.post( + "/projects/preview", + response_model=ProjectImportPreviewResponse, + status_code=status.HTTP_200_OK, +) +async def preview_project_import( + file: UploadFile = File(..., + description="Project import file (CSV or Excel)"), + _: User = Depends(require_roles(*MANAGE_ROLES)), + ingestion_service: ImportIngestionService = Depends( + get_import_ingestion_service), +) -> ProjectImportPreviewResponse: + if not file.filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Filename is required for import.", + ) + + stream = await _read_upload_file(file) + + try: + preview = ingestion_service.preview_projects(stream, file.filename) + except UnsupportedImportFormat as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + + return ProjectImportPreviewResponse.model_validate(preview) + + +@router.post( + "/scenarios/preview", + response_model=ScenarioImportPreviewResponse, + status_code=status.HTTP_200_OK, +) +async def preview_scenario_import( + file: UploadFile = File(..., + description="Scenario import file (CSV or Excel)"), + _: User = Depends(require_roles(*MANAGE_ROLES)), + ingestion_service: ImportIngestionService = Depends( + get_import_ingestion_service), +) -> ScenarioImportPreviewResponse: + if not file.filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Filename is required for import.", + ) + + stream = await _read_upload_file(file) + + try: + preview = ingestion_service.preview_scenarios(stream, file.filename) + except UnsupportedImportFormat as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + + return ScenarioImportPreviewResponse.model_validate(preview) + + +def _value_error_status(exc: ValueError) -> int: + detail = str(exc) + if detail.lower().startswith("unknown"): + return status.HTTP_404_NOT_FOUND + return status.HTTP_400_BAD_REQUEST + + +@router.post( + "/projects/commit", + response_model=ProjectImportCommitResponse, + status_code=status.HTTP_200_OK, +) +async def commit_project_import_endpoint( + payload: ImportCommitRequest, + _: User = Depends(require_roles(*MANAGE_ROLES)), + ingestion_service: ImportIngestionService = Depends( + get_import_ingestion_service), +) -> ProjectImportCommitResponse: + try: + result = ingestion_service.commit_project_import(payload.token) + except ValueError as exc: + raise HTTPException( + status_code=_value_error_status(exc), + detail=str(exc), + ) from exc + + return ProjectImportCommitResponse.model_validate(result) + + +@router.post( + "/scenarios/commit", + response_model=ScenarioImportCommitResponse, + status_code=status.HTTP_200_OK, +) +async def commit_scenario_import_endpoint( + payload: ImportCommitRequest, + _: User = Depends(require_roles(*MANAGE_ROLES)), + ingestion_service: ImportIngestionService = Depends( + get_import_ingestion_service), +) -> ScenarioImportCommitResponse: + try: + result = ingestion_service.commit_scenario_import(payload.token) + except ValueError as exc: + raise HTTPException( + status_code=_value_error_status(exc), + detail=str(exc), + ) from exc + + return ScenarioImportCommitResponse.model_validate(result)