from __future__ import annotations from typing import List from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from dependencies import ( get_pricing_metadata, get_unit_of_work, require_any_role, require_any_role_html, require_project_resource, require_project_resource_html, require_roles, require_roles_html, ) from models import MiningOperationType, Project, ScenarioStatus, User from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate from services.exceptions import EntityConflictError from services.pricing import PricingMetadata from services.unit_of_work import UnitOfWork from routes.template_filters import create_templates router = APIRouter(prefix="/projects", tags=["Projects"]) templates = create_templates() READ_ROLES = ("viewer", "analyst", "project_manager", "admin") MANAGE_ROLES = ("project_manager", "admin") def _to_read_model(project: Project) -> ProjectRead: return ProjectRead.model_validate(project) def _require_project_repo(uow: UnitOfWork): if not uow.projects: raise RuntimeError("Project repository not initialised") return uow.projects def _operation_type_choices() -> list[tuple[str, str]]: return [ (op.value, op.name.replace("_", " ").title()) for op in MiningOperationType ] @router.get("", response_model=List[ProjectRead]) def list_projects( _: User = Depends(require_any_role(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), ) -> List[ProjectRead]: projects = _require_project_repo(uow).list() return [_to_read_model(project) for project in projects] @router.post("", response_model=ProjectRead, status_code=status.HTTP_201_CREATED) def create_project( payload: ProjectCreate, _: User = Depends(require_roles(*MANAGE_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), metadata: PricingMetadata = Depends(get_pricing_metadata), ) -> ProjectRead: project = Project(**payload.model_dump()) try: created = _require_project_repo(uow).create(project) except EntityConflictError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc) ) from exc default_settings = uow.ensure_default_pricing_settings( metadata=metadata).settings uow.set_project_pricing_settings(created, default_settings) return _to_read_model(created) @router.get( "/ui", response_class=HTMLResponse, include_in_schema=False, name="projects.project_list_page", ) def project_list_page( request: Request, _: User = Depends(require_any_role_html(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), ) -> HTMLResponse: projects = _require_project_repo(uow).list(with_children=True) for project in projects: setattr(project, "scenario_count", len(project.scenarios)) return templates.TemplateResponse( request, "projects/list.html", { "projects": projects, }, ) @router.get( "/create", response_class=HTMLResponse, include_in_schema=False, name="projects.create_project_form", ) def create_project_form( request: Request, _: User = Depends(require_roles_html(*MANAGE_ROLES)), ) -> HTMLResponse: return templates.TemplateResponse( request, "projects/form.html", { "project": None, "operation_types": _operation_type_choices(), "form_action": request.url_for("projects.create_project_submit"), "cancel_url": request.url_for("projects.project_list_page"), }, ) @router.post( "/create", include_in_schema=False, name="projects.create_project_submit", ) def create_project_submit( request: Request, _: User = Depends(require_roles_html(*MANAGE_ROLES)), name: str = Form(...), location: str | None = Form(None), operation_type: str = Form(...), description: str | None = Form(None), uow: UnitOfWork = Depends(get_unit_of_work), metadata: PricingMetadata = Depends(get_pricing_metadata), ): def _normalise(value: str | None) -> str | None: if value is None: return None value = value.strip() return value or None try: op_type = MiningOperationType(operation_type) except ValueError: return templates.TemplateResponse( request, "projects/form.html", { "project": None, "operation_types": _operation_type_choices(), "form_action": request.url_for("projects.create_project_submit"), "cancel_url": request.url_for("projects.project_list_page"), "error": "Invalid operation type.", }, status_code=status.HTTP_400_BAD_REQUEST, ) project = Project( name=name.strip(), location=_normalise(location), operation_type=op_type, description=_normalise(description), ) try: created = _require_project_repo(uow).create(project) except EntityConflictError: return templates.TemplateResponse( request, "projects/form.html", { "project": project, "operation_types": _operation_type_choices(), "form_action": request.url_for("projects.create_project_submit"), "cancel_url": request.url_for("projects.project_list_page"), "error": "Project with this name already exists.", }, status_code=status.HTTP_409_CONFLICT, ) default_settings = uow.ensure_default_pricing_settings( metadata=metadata).settings uow.set_project_pricing_settings(created, default_settings) return RedirectResponse( request.url_for("projects.project_list_page"), status_code=status.HTTP_303_SEE_OTHER, ) @router.get("/{project_id}", response_model=ProjectRead) def get_project(project: Project = Depends(require_project_resource())) -> ProjectRead: return _to_read_model(project) @router.put("/{project_id}", response_model=ProjectRead) def update_project( payload: ProjectUpdate, project: Project = Depends( require_project_resource(require_manage=True) ), uow: UnitOfWork = Depends(get_unit_of_work), ) -> ProjectRead: update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(project, field, value) uow.flush() return _to_read_model(project) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_project( project: Project = Depends(require_project_resource(require_manage=True)), uow: UnitOfWork = Depends(get_unit_of_work), ) -> None: _require_project_repo(uow).delete(project.id) @router.get( "/{project_id}/view", response_class=HTMLResponse, include_in_schema=False, name="projects.view_project", ) def view_project( request: Request, _: User = Depends(require_any_role_html(*READ_ROLES)), project: Project = Depends(require_project_resource_html()), uow: UnitOfWork = Depends(get_unit_of_work), ) -> HTMLResponse: project = _require_project_repo(uow).get(project.id, with_children=True) scenarios = sorted(project.scenarios, key=lambda s: s.created_at) scenario_stats = { "total": len(scenarios), "active": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.ACTIVE), "draft": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.DRAFT), "archived": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.ARCHIVED), "latest_update": max( (scenario.updated_at for scenario in scenarios if scenario.updated_at), default=None, ), } return templates.TemplateResponse( request, "projects/detail.html", { "project": project, "scenarios": scenarios, "scenario_stats": scenario_stats, }, ) @router.get( "/{project_id}/edit", response_class=HTMLResponse, include_in_schema=False, name="projects.edit_project_form", ) def edit_project_form( request: Request, _: User = Depends(require_roles_html(*MANAGE_ROLES)), project: Project = Depends( require_project_resource_html(require_manage=True) ), ) -> HTMLResponse: return templates.TemplateResponse( request, "projects/form.html", { "project": project, "operation_types": _operation_type_choices(), "form_action": request.url_for( "projects.edit_project_submit", project_id=project.id ), "cancel_url": request.url_for( "projects.view_project", project_id=project.id ), }, ) @router.post( "/{project_id}/edit", include_in_schema=False, name="projects.edit_project_submit", ) def edit_project_submit( request: Request, _: User = Depends(require_roles_html(*MANAGE_ROLES)), project: Project = Depends( require_project_resource_html(require_manage=True) ), name: str = Form(...), location: str | None = Form(None), operation_type: str | None = Form(None), description: str | None = Form(None), uow: UnitOfWork = Depends(get_unit_of_work), ): def _normalise(value: str | None) -> str | None: if value is None: return None value = value.strip() return value or None project.name = name.strip() project.location = _normalise(location) if operation_type: try: project.operation_type = MiningOperationType(operation_type) except ValueError: return templates.TemplateResponse( request, "projects/form.html", { "project": project, "operation_types": _operation_type_choices(), "form_action": request.url_for( "projects.edit_project_submit", project_id=project.id ), "cancel_url": request.url_for( "projects.view_project", project_id=project.id ), "error": "Invalid operation type.", }, status_code=status.HTTP_400_BAD_REQUEST, ) project.description = _normalise(description) uow.flush() return RedirectResponse( request.url_for("projects.view_project", project_id=project.id), status_code=status.HTTP_303_SEE_OTHER, )