""" Task Dependencies API Router Provides CRUD operations for task dependencies used in Gantt view. Includes circular dependency detection and date constraint validation. """ import uuid from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from typing import Optional from app.core.database import get_db from app.models import User, Task, TaskDependency, AuditAction from app.schemas.task_dependency import ( TaskDependencyCreate, TaskDependencyUpdate, TaskDependencyResponse, TaskDependencyListResponse, TaskInfo ) from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access from app.middleware.audit import get_audit_metadata from app.services.audit_service import AuditService from app.services.dependency_service import DependencyService, DependencyValidationError router = APIRouter(tags=["task-dependencies"]) def dependency_to_response( dep: TaskDependency, include_tasks: bool = True ) -> TaskDependencyResponse: """Convert TaskDependency model to response schema.""" predecessor_info = None successor_info = None if include_tasks: if dep.predecessor: predecessor_info = TaskInfo( id=dep.predecessor.id, title=dep.predecessor.title, start_date=dep.predecessor.start_date, due_date=dep.predecessor.due_date ) if dep.successor: successor_info = TaskInfo( id=dep.successor.id, title=dep.successor.title, start_date=dep.successor.start_date, due_date=dep.successor.due_date ) return TaskDependencyResponse( id=dep.id, predecessor_id=dep.predecessor_id, successor_id=dep.successor_id, dependency_type=dep.dependency_type, lag_days=dep.lag_days, created_at=dep.created_at, predecessor=predecessor_info, successor=successor_info ) @router.post( "/api/tasks/{task_id}/dependencies", response_model=TaskDependencyResponse, status_code=status.HTTP_201_CREATED ) async def create_dependency( task_id: str, dependency_data: TaskDependencyCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Add a dependency to a task (the task becomes the successor). The predecessor_id in the request body specifies which task must complete first. The task_id in the URL becomes the successor (depends on the predecessor). Validates: - Both tasks exist and are in the same project - No self-reference - No duplicate dependency - No circular dependency - Dependency limit not exceeded """ # Get the successor task (from URL) successor = db.query(Task).filter(Task.id == task_id).first() if not successor: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) # Check edit permission on successor if not check_task_edit_access(current_user, successor, successor.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied" ) # Validate the dependency try: DependencyService.validate_dependency( db, predecessor_id=dependency_data.predecessor_id, successor_id=task_id ) except DependencyValidationError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ "error_type": e.error_type, "message": e.message, "details": e.details } ) # Create the dependency dependency = TaskDependency( id=str(uuid.uuid4()), predecessor_id=dependency_data.predecessor_id, successor_id=task_id, dependency_type=dependency_data.dependency_type.value, lag_days=dependency_data.lag_days ) db.add(dependency) # Audit log AuditService.log_event( db=db, event_type="task.dependency.create", resource_type="task_dependency", action=AuditAction.CREATE, user_id=current_user.id, resource_id=dependency.id, changes=[{ "field": "dependency", "old_value": None, "new_value": { "predecessor_id": dependency.predecessor_id, "successor_id": dependency.successor_id, "dependency_type": dependency.dependency_type, "lag_days": dependency.lag_days } }], request_metadata=get_audit_metadata(request) ) db.commit() db.refresh(dependency) return dependency_to_response(dependency) @router.get( "/api/tasks/{task_id}/dependencies", response_model=TaskDependencyListResponse ) async def list_task_dependencies( task_id: str, direction: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Get all dependencies for a task. Args: task_id: The task to get dependencies for direction: Optional filter - 'predecessors': Only get tasks this task depends on - 'successors': Only get tasks that depend on this task - None: Get both Returns all dependencies where the task is either the predecessor or successor. """ task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) if not check_task_access(current_user, task, task.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) dependencies = [] if direction is None or direction == "predecessors": # Get dependencies where this task is the successor (predecessors) predecessor_deps = db.query(TaskDependency).filter( TaskDependency.successor_id == task_id ).all() dependencies.extend(predecessor_deps) if direction is None or direction == "successors": # Get dependencies where this task is the predecessor (successors) successor_deps = db.query(TaskDependency).filter( TaskDependency.predecessor_id == task_id ).all() # Avoid duplicates if direction is None if direction is None: for dep in successor_deps: if dep not in dependencies: dependencies.append(dep) else: dependencies.extend(successor_deps) return TaskDependencyListResponse( dependencies=[dependency_to_response(d) for d in dependencies], total=len(dependencies) ) @router.get( "/api/task-dependencies/{dependency_id}", response_model=TaskDependencyResponse ) async def get_dependency( dependency_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Get a specific dependency by ID.""" dependency = db.query(TaskDependency).filter( TaskDependency.id == dependency_id ).first() if not dependency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Dependency not found" ) # Check access via the successor task task = dependency.successor if not check_task_access(current_user, task, task.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) return dependency_to_response(dependency) @router.patch( "/api/task-dependencies/{dependency_id}", response_model=TaskDependencyResponse ) async def update_dependency( dependency_id: str, update_data: TaskDependencyUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Update a dependency's type or lag days. Cannot change predecessor_id or successor_id - delete and recreate instead. """ dependency = db.query(TaskDependency).filter( TaskDependency.id == dependency_id ).first() if not dependency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Dependency not found" ) # Check edit permission via the successor task task = dependency.successor if not check_task_edit_access(current_user, task, task.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied" ) # Track changes for audit old_values = { "dependency_type": dependency.dependency_type, "lag_days": dependency.lag_days } # Update fields if update_data.dependency_type is not None: dependency.dependency_type = update_data.dependency_type.value if update_data.lag_days is not None: dependency.lag_days = update_data.lag_days new_values = { "dependency_type": dependency.dependency_type, "lag_days": dependency.lag_days } # Audit log changes = AuditService.detect_changes(old_values, new_values) if changes: AuditService.log_event( db=db, event_type="task.dependency.update", resource_type="task_dependency", action=AuditAction.UPDATE, user_id=current_user.id, resource_id=dependency.id, changes=changes, request_metadata=get_audit_metadata(request) ) db.commit() db.refresh(dependency) return dependency_to_response(dependency) @router.delete( "/api/task-dependencies/{dependency_id}", status_code=status.HTTP_204_NO_CONTENT ) async def delete_dependency( dependency_id: str, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete a dependency.""" dependency = db.query(TaskDependency).filter( TaskDependency.id == dependency_id ).first() if not dependency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Dependency not found" ) # Check edit permission via the successor task task = dependency.successor if not check_task_edit_access(current_user, task, task.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied" ) # Audit log AuditService.log_event( db=db, event_type="task.dependency.delete", resource_type="task_dependency", action=AuditAction.DELETE, user_id=current_user.id, resource_id=dependency.id, changes=[{ "field": "dependency", "old_value": { "predecessor_id": dependency.predecessor_id, "successor_id": dependency.successor_id, "dependency_type": dependency.dependency_type, "lag_days": dependency.lag_days }, "new_value": None }], request_metadata=get_audit_metadata(request) ) db.delete(dependency) db.commit() return None @router.get( "/api/projects/{project_id}/dependencies", response_model=TaskDependencyListResponse ) async def list_project_dependencies( project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Get all dependencies for a project. Useful for rendering the full Gantt chart with all dependency arrows. """ from app.models import 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" ) from app.middleware.auth import check_project_access if not check_project_access(current_user, project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) # Get all dependencies for tasks in this project (exclude soft-deleted tasks) # Create aliases for joining both predecessor and successor from sqlalchemy.orm import aliased Successor = aliased(Task) Predecessor = aliased(Task) dependencies = db.query(TaskDependency).join( Successor, TaskDependency.successor_id == Successor.id ).join( Predecessor, TaskDependency.predecessor_id == Predecessor.id ).filter( Successor.project_id == project_id, Successor.is_deleted == False, Predecessor.is_deleted == False ).all() return TaskDependencyListResponse( dependencies=[dependency_to_response(d) for d in dependencies], total=len(dependencies) )