""" 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.core.rate_limiter import limiter from app.core.config import settings from app.models import User, Task, TaskDependency, AuditAction from app.schemas.task_dependency import ( TaskDependencyCreate, TaskDependencyUpdate, TaskDependencyResponse, TaskDependencyListResponse, TaskInfo, BulkDependencyCreate, BulkDependencyValidationResult, BulkDependencyCreateResponse, ) 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) ) @router.post( "/api/projects/{project_id}/dependencies/validate", response_model=BulkDependencyValidationResult ) async def validate_bulk_dependencies( project_id: str, bulk_data: BulkDependencyCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Validate a batch of dependencies without creating them. This endpoint checks for: - Self-references - Cross-project dependencies - Duplicate dependencies - Circular dependencies (including cycles that would be created by the batch) Returns validation results without modifying the database. """ from app.models import Project from app.middleware.auth import check_project_access 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" ) # Convert to tuple format for validation dependencies = [ (dep.predecessor_id, dep.successor_id) for dep in bulk_data.dependencies ] errors = DependencyService.validate_bulk_dependencies(db, dependencies, project_id) return BulkDependencyValidationResult( valid=len(errors) == 0, errors=errors ) @router.post( "/api/projects/{project_id}/dependencies/bulk", response_model=BulkDependencyCreateResponse, status_code=status.HTTP_201_CREATED ) @limiter.limit(settings.RATE_LIMIT_HEAVY) async def create_bulk_dependencies( request: Request, project_id: str, bulk_data: BulkDependencyCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Create multiple dependencies at once. This endpoint: 1. Validates all dependencies together for cycle detection 2. Creates valid dependencies 3. Returns both created dependencies and any failures Cycle detection considers all dependencies in the batch together, so cycles that would only appear when all dependencies are added will be caught. Rate limited: 5 requests per minute (heavy tier). """ from app.models import Project from app.middleware.auth import check_project_edit_access 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="Permission denied" ) # Convert to tuple format for validation dependencies_to_validate = [ (dep.predecessor_id, dep.successor_id) for dep in bulk_data.dependencies ] # Validate all dependencies together errors = DependencyService.validate_bulk_dependencies( db, dependencies_to_validate, project_id ) # Build a set of failed dependency pairs for quick lookup failed_pairs = set() for error in errors: pair = (error.get("predecessor_id"), error.get("successor_id")) failed_pairs.add(pair) created_dependencies = [] failed_items = errors # Include validation errors # Create dependencies that passed validation for dep_data in bulk_data.dependencies: pair = (dep_data.predecessor_id, dep_data.successor_id) if pair in failed_pairs: continue # Additional check: verify dependency limit for successor current_count = db.query(TaskDependency).filter( TaskDependency.successor_id == dep_data.successor_id ).count() if current_count >= DependencyService.MAX_DIRECT_DEPENDENCIES: failed_items.append({ "error_type": "limit_exceeded", "predecessor_id": dep_data.predecessor_id, "successor_id": dep_data.successor_id, "message": f"Successor task already has {DependencyService.MAX_DIRECT_DEPENDENCIES} dependencies" }) continue # Create the dependency dependency = TaskDependency( id=str(uuid.uuid4()), predecessor_id=dep_data.predecessor_id, successor_id=dep_data.successor_id, dependency_type=dep_data.dependency_type.value, lag_days=dep_data.lag_days ) db.add(dependency) created_dependencies.append(dependency) # Audit log for each created dependency 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() # Refresh created dependencies to get relationships for dep in created_dependencies: db.refresh(dep) return BulkDependencyCreateResponse( created=[dependency_to_response(d) for d in created_dependencies], failed=failed_items, total_created=len(created_dependencies), total_failed=len(failed_items) )