""" FastAPI routes for Compliance Projects (Multi-Projekt-Architektur). Endpoints: - GET /v1/projects → List all projects for a tenant - POST /v1/projects → Create a new project - GET /v1/projects/{project_id} → Get a single project - PATCH /v1/projects/{project_id} → Update project (name, description) - DELETE /v1/projects/{project_id} → Archive project (soft delete) """ import json import logging from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import text from database import SessionLocal from .tenant_utils import get_tenant_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/projects", tags=["projects"]) # ============================================================================= # REQUEST/RESPONSE MODELS # ============================================================================= class CreateProjectRequest(BaseModel): name: str description: str = "" customer_type: str = "new" # 'new' | 'existing' copy_from_project_id: Optional[str] = None # Copy company profile from existing project class UpdateProjectRequest(BaseModel): name: Optional[str] = None description: Optional[str] = None class ProjectResponse(BaseModel): id: str tenant_id: str name: str description: str customer_type: str status: str project_version: int completion_percentage: int created_at: str updated_at: str # ============================================================================= # HELPERS # ============================================================================= def _row_to_response(row) -> dict: """Convert a DB row to ProjectResponse dict.""" return { "id": str(row.id), "tenant_id": row.tenant_id, "name": row.name, "description": row.description or "", "customer_type": row.customer_type or "new", "status": row.status or "active", "project_version": row.project_version or 1, "completion_percentage": row.completion_percentage or 0, "created_at": row.created_at.isoformat() if row.created_at else "", "updated_at": row.updated_at.isoformat() if row.updated_at else "", } # ============================================================================= # ENDPOINTS # ============================================================================= @router.get("") async def list_projects( tenant_id: str = Depends(get_tenant_id), include_archived: bool = False, ): """List all projects for the tenant.""" db = SessionLocal() try: if include_archived: query = text(""" SELECT id, tenant_id, name, description, customer_type, status, project_version, completion_percentage, created_at, updated_at FROM compliance_projects WHERE tenant_id = :tenant_id AND status != 'deleted' ORDER BY created_at DESC """) else: query = text(""" SELECT id, tenant_id, name, description, customer_type, status, project_version, completion_percentage, created_at, updated_at FROM compliance_projects WHERE tenant_id = :tenant_id AND status = 'active' ORDER BY created_at DESC """) result = db.execute(query, {"tenant_id": tenant_id}) rows = result.fetchall() return { "projects": [_row_to_response(row) for row in rows], "total": len(rows), } finally: db.close() @router.post("", status_code=201) async def create_project( body: CreateProjectRequest, tenant_id: str = Depends(get_tenant_id), ): """Create a new compliance project. Optionally copies the company profile (companyProfile) from an existing project's sdk_states into the new project's state. This allows a tenant to start a new project for a subsidiary with the same base data. """ db = SessionLocal() try: # Create the project row result = db.execute( text(""" INSERT INTO compliance_projects (tenant_id, name, description, customer_type, status) VALUES (:tenant_id, :name, :description, :customer_type, 'active') RETURNING id, tenant_id, name, description, customer_type, status, project_version, completion_percentage, created_at, updated_at """), { "tenant_id": tenant_id, "name": body.name, "description": body.description, "customer_type": body.customer_type, }, ) project_row = result.fetchone() project_id = str(project_row.id) # Build initial SDK state initial_state = { "version": "1.0.0", "projectVersion": 1, "tenantId": tenant_id, "projectId": project_id, "customerType": body.customer_type, "companyProfile": None, } # If copy_from_project_id is provided, copy company profile if body.copy_from_project_id: source = db.execute( text(""" SELECT state FROM sdk_states WHERE tenant_id = :tenant_id AND project_id = :project_id """), { "tenant_id": tenant_id, "project_id": body.copy_from_project_id, }, ).fetchone() if source and source.state: source_state = source.state if isinstance(source.state, dict) else json.loads(source.state) if "companyProfile" in source_state: initial_state["companyProfile"] = source_state["companyProfile"] if "customerType" in source_state: initial_state["customerType"] = source_state["customerType"] # Create the sdk_states row for this project db.execute( text(""" INSERT INTO sdk_states (tenant_id, project_id, state, version, created_at, updated_at) VALUES (:tenant_id, :project_id, CAST(:state AS jsonb), 1, NOW(), NOW()) """), { "tenant_id": tenant_id, "project_id": project_id, "state": json.dumps(initial_state), }, ) db.commit() logger.info("Created project %s for tenant %s", project_id, tenant_id) return _row_to_response(project_row) except Exception: db.rollback() raise finally: db.close() @router.get("/{project_id}") async def get_project( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Get a single project by ID (tenant-scoped).""" db = SessionLocal() try: result = db.execute( text(""" SELECT id, tenant_id, name, description, customer_type, status, project_version, completion_percentage, created_at, updated_at FROM compliance_projects WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted' """), {"project_id": project_id, "tenant_id": tenant_id}, ) row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="Project not found") return _row_to_response(row) finally: db.close() @router.patch("/{project_id}") async def update_project( project_id: str, body: UpdateProjectRequest, tenant_id: str = Depends(get_tenant_id), ): """Update project name/description.""" db = SessionLocal() try: # Build SET clause dynamically updates = {} set_parts = ["updated_at = NOW()"] if body.name is not None: set_parts.append("name = :name") updates["name"] = body.name if body.description is not None: set_parts.append("description = :description") updates["description"] = body.description updates["project_id"] = project_id updates["tenant_id"] = tenant_id result = db.execute( text(f""" UPDATE compliance_projects SET {', '.join(set_parts)} WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted' RETURNING id, tenant_id, name, description, customer_type, status, project_version, completion_percentage, created_at, updated_at """), updates, ) row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="Project not found") db.commit() return _row_to_response(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.delete("/{project_id}") async def archive_project( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Soft-delete (archive) a project.""" db = SessionLocal() try: result = db.execute( text(""" UPDATE compliance_projects SET status = 'archived', archived_at = NOW(), updated_at = NOW() WHERE id = :project_id AND tenant_id = :tenant_id AND status = 'active' RETURNING id """), {"project_id": project_id, "tenant_id": tenant_id}, ) row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="Project not found or already archived") db.commit() return {"success": True, "id": str(row.id), "status": "archived"} except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.post("/{project_id}/restore") async def restore_project( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Restore an archived project back to active.""" db = SessionLocal() try: result = db.execute( text(""" UPDATE compliance_projects SET status = 'active', archived_at = NULL, updated_at = NOW() WHERE id = :project_id AND tenant_id = :tenant_id AND status = 'archived' RETURNING id, tenant_id, name, description, customer_type, status, project_version, completion_percentage, created_at, updated_at """), {"project_id": project_id, "tenant_id": tenant_id}, ) row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="Project not found or not archived") db.commit() logger.info("Restored project %s for tenant %s", project_id, tenant_id) return _row_to_response(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.delete("/{project_id}/permanent") async def permanently_delete_project( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Permanently delete a project and all associated data.""" db = SessionLocal() try: # Verify project exists and belongs to tenant check = db.execute( text(""" SELECT id FROM compliance_projects WHERE id = :project_id AND tenant_id = :tenant_id """), {"project_id": project_id, "tenant_id": tenant_id}, ).fetchone() if not check: raise HTTPException(status_code=404, detail="Project not found") # Delete sdk_states (CASCADE should handle this, but be explicit) db.execute( text("DELETE FROM sdk_states WHERE project_id = :project_id AND tenant_id = :tenant_id"), {"project_id": project_id, "tenant_id": tenant_id}, ) # Delete the project itself db.execute( text("DELETE FROM compliance_projects WHERE id = :project_id AND tenant_id = :tenant_id"), {"project_id": project_id, "tenant_id": tenant_id}, ) db.commit() logger.info("Permanently deleted project %s for tenant %s", project_id, tenant_id) return {"success": True, "id": project_id, "status": "deleted"} except HTTPException: raise except Exception: db.rollback() raise finally: db.close()