import uuid from typing import List from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from app.core.database import get_db from app.models import User, Space, Project, TaskStatus, AuditAction from app.models.task_status import DEFAULT_STATUSES from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithDetails from app.schemas.task_status import TaskStatusResponse from app.middleware.auth import ( get_current_user, check_space_access, check_space_edit_access, check_project_access, check_project_edit_access ) from app.middleware.audit import get_audit_metadata from app.services.audit_service import AuditService router = APIRouter(tags=["projects"]) def create_default_statuses(db: Session, project_id: str): """Create default task statuses for a new project.""" for status_data in DEFAULT_STATUSES: status = TaskStatus( id=str(uuid.uuid4()), project_id=project_id, **status_data ) db.add(status) @router.get("/api/spaces/{space_id}/projects", response_model=List[ProjectWithDetails]) async def list_projects_in_space( space_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ List all projects in a space that the user can access. """ space = db.query(Space).filter(Space.id == space_id, Space.is_active == True).first() if not space: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Space not found", ) if not check_space_access(current_user, space): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) projects = db.query(Project).filter(Project.space_id == space_id).all() # Filter by project access accessible_projects = [p for p in projects if check_project_access(current_user, p)] result = [] for project in accessible_projects: task_count = len(project.tasks) if project.tasks else 0 result.append(ProjectWithDetails( id=project.id, space_id=project.space_id, title=project.title, description=project.description, owner_id=project.owner_id, budget=project.budget, start_date=project.start_date, end_date=project.end_date, security_level=project.security_level, status=project.status, department_id=project.department_id, created_at=project.created_at, updated_at=project.updated_at, owner_name=project.owner.name if project.owner else None, space_name=project.space.name if project.space else None, department_name=project.department.name if project.department else None, task_count=task_count, )) return result @router.post("/api/spaces/{space_id}/projects", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) async def create_project( space_id: str, project_data: ProjectCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Create a new project in a space. """ space = db.query(Space).filter(Space.id == space_id, Space.is_active == True).first() if not space: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Space not found", ) if not check_space_access(current_user, space): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) project = Project( id=str(uuid.uuid4()), space_id=space_id, title=project_data.title, description=project_data.description, owner_id=current_user.id, budget=project_data.budget, start_date=project_data.start_date, end_date=project_data.end_date, security_level=project_data.security_level.value if project_data.security_level else "department", department_id=project_data.department_id or current_user.department_id, ) db.add(project) db.flush() # Get the project ID # Create default task statuses create_default_statuses(db, project.id) # Audit log AuditService.log_event( db=db, event_type="project.create", resource_type="project", action=AuditAction.CREATE, user_id=current_user.id, resource_id=project.id, changes=[{"field": "title", "old_value": None, "new_value": project.title}], request_metadata=get_audit_metadata(request), ) db.commit() db.refresh(project) return project @router.get("/api/projects/{project_id}", response_model=ProjectWithDetails) async def get_project( project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Get a project by ID. """ project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found", ) if not check_project_access(current_user, project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) task_count = len(project.tasks) if project.tasks else 0 return ProjectWithDetails( id=project.id, space_id=project.space_id, title=project.title, description=project.description, owner_id=project.owner_id, budget=project.budget, start_date=project.start_date, end_date=project.end_date, security_level=project.security_level, status=project.status, department_id=project.department_id, created_at=project.created_at, updated_at=project.updated_at, owner_name=project.owner.name if project.owner else None, space_name=project.space.name if project.space else None, department_name=project.department.name if project.department else None, task_count=task_count, ) @router.patch("/api/projects/{project_id}", response_model=ProjectResponse) async def update_project( project_id: str, project_data: ProjectUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Update a project. """ project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found", ) if not check_project_edit_access(current_user, project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only project owner can update", ) # Capture old values for audit old_values = { "title": project.title, "description": project.description, "budget": project.budget, "start_date": project.start_date, "end_date": project.end_date, "security_level": project.security_level, "status": project.status, } # Update fields update_data = project_data.model_dump(exclude_unset=True) for field, value in update_data.items(): if field == "security_level" and value: setattr(project, field, value.value) else: setattr(project, field, value) # Capture new values and log changes new_values = { "title": project.title, "description": project.description, "budget": project.budget, "start_date": project.start_date, "end_date": project.end_date, "security_level": project.security_level, "status": project.status, } changes = AuditService.detect_changes(old_values, new_values) if changes: AuditService.log_event( db=db, event_type="project.update", resource_type="project", action=AuditAction.UPDATE, user_id=current_user.id, resource_id=project.id, changes=changes, request_metadata=get_audit_metadata(request), ) db.commit() db.refresh(project) return project @router.delete("/api/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project( project_id: str, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Delete a project (hard delete, cascades to tasks). """ project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found", ) if not check_project_edit_access(current_user, project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only project owner can delete", ) # Audit log before deletion (this is a high-sensitivity event that triggers alert) AuditService.log_event( db=db, event_type="project.delete", resource_type="project", action=AuditAction.DELETE, user_id=current_user.id, resource_id=project.id, changes=[{"field": "title", "old_value": project.title, "new_value": None}], request_metadata=get_audit_metadata(request), ) db.delete(project) db.commit() return None @router.get("/api/projects/{project_id}/statuses", response_model=List[TaskStatusResponse]) async def list_project_statuses( project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ List all task statuses for a project. """ project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found", ) if not check_project_access(current_user, project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) statuses = db.query(TaskStatus).filter( TaskStatus.project_id == project_id ).order_by(TaskStatus.position).all() return statuses