""" Dependency Service Handles task dependency validation including: - Circular dependency detection using DFS - Date constraint validation based on dependency types - Self-reference prevention - Cross-project dependency prevention """ from typing import List, Optional, Set, Tuple, Dict, Any from collections import defaultdict from sqlalchemy.orm import Session from datetime import datetime, timedelta from app.models import Task, TaskDependency class DependencyValidationError(Exception): """Custom exception for dependency validation errors.""" def __init__(self, error_type: str, message: str, details: Optional[dict] = None): self.error_type = error_type self.message = message self.details = details or {} super().__init__(message) class DependencyService: """Service for managing task dependencies with validation.""" # Maximum number of direct dependencies per task (as per spec) MAX_DIRECT_DEPENDENCIES = 10 @staticmethod def detect_circular_dependency( db: Session, predecessor_id: str, successor_id: str, project_id: str ) -> Optional[List[str]]: """ Detect if adding a dependency would create a circular reference. Uses DFS to traverse from the successor to check if we can reach the predecessor through existing dependencies. Args: db: Database session predecessor_id: The task that must complete first successor_id: The task that depends on the predecessor project_id: Project ID to scope the query Returns: List of task IDs forming the cycle if circular, None otherwise """ # If adding predecessor -> successor, check if successor can reach predecessor # This would mean predecessor depends (transitively) on successor, creating a cycle # Build adjacency list for the project's dependencies dependencies = db.query(TaskDependency).join( Task, TaskDependency.successor_id == Task.id ).filter(Task.project_id == project_id).all() # Graph: successor -> [predecessors] # We need to check if predecessor is reachable from successor # by following the chain of "what does this task depend on" graph: Dict[str, List[str]] = defaultdict(list) for dep in dependencies: graph[dep.successor_id].append(dep.predecessor_id) # Simulate adding the new edge graph[successor_id].append(predecessor_id) # DFS to find if there's a path from predecessor back to successor # (which would complete a cycle) visited: Set[str] = set() path: List[str] = [] in_path: Set[str] = set() def dfs(node: str) -> Optional[List[str]]: """DFS traversal to detect cycles.""" if node in in_path: # Found a cycle - return the cycle path cycle_start = path.index(node) return path[cycle_start:] + [node] if node in visited: return None visited.add(node) in_path.add(node) path.append(node) for neighbor in graph.get(node, []): result = dfs(neighbor) if result: return result path.pop() in_path.remove(node) return None # Start DFS from the successor to check if we can reach back to it return dfs(successor_id) @staticmethod def validate_dependency( db: Session, predecessor_id: str, successor_id: str ) -> None: """ Validate that a dependency can be created. Raises DependencyValidationError if validation fails. Checks: 1. Self-reference 2. Both tasks exist 3. Both tasks are in the same project 4. No duplicate dependency 5. No circular dependency 6. Dependency limit not exceeded """ # Check self-reference if predecessor_id == successor_id: raise DependencyValidationError( error_type="self_reference", message="A task cannot depend on itself" ) # Get both tasks predecessor = db.query(Task).filter(Task.id == predecessor_id).first() successor = db.query(Task).filter(Task.id == successor_id).first() if not predecessor: raise DependencyValidationError( error_type="not_found", message="Predecessor task not found", details={"task_id": predecessor_id} ) if not successor: raise DependencyValidationError( error_type="not_found", message="Successor task not found", details={"task_id": successor_id} ) # Check same project if predecessor.project_id != successor.project_id: raise DependencyValidationError( error_type="cross_project", message="Dependencies can only be created between tasks in the same project", details={ "predecessor_project_id": predecessor.project_id, "successor_project_id": successor.project_id } ) # Check duplicate existing = db.query(TaskDependency).filter( TaskDependency.predecessor_id == predecessor_id, TaskDependency.successor_id == successor_id ).first() if existing: raise DependencyValidationError( error_type="duplicate", message="This dependency already exists" ) # Check dependency limit current_count = db.query(TaskDependency).filter( TaskDependency.successor_id == successor_id ).count() if current_count >= DependencyService.MAX_DIRECT_DEPENDENCIES: raise DependencyValidationError( error_type="limit_exceeded", message=f"A task cannot have more than {DependencyService.MAX_DIRECT_DEPENDENCIES} direct dependencies", details={"current_count": current_count} ) # Check circular dependency cycle = DependencyService.detect_circular_dependency( db, predecessor_id, successor_id, predecessor.project_id ) if cycle: raise DependencyValidationError( error_type="circular", message="Adding this dependency would create a circular reference", details={"cycle": cycle} ) @staticmethod def validate_date_constraints( task: Task, start_date: Optional[datetime], due_date: Optional[datetime], db: Session ) -> List[Dict[str, Any]]: """ Validate date changes against dependency constraints. Returns a list of constraint violations (empty if valid). Dependency type meanings: - FS: predecessor.due_date + lag <= successor.start_date - SS: predecessor.start_date + lag <= successor.start_date - FF: predecessor.due_date + lag <= successor.due_date - SF: predecessor.start_date + lag <= successor.due_date """ violations = [] # Use provided dates or fall back to current task dates new_start = start_date if start_date is not None else task.start_date new_due = due_date if due_date is not None else task.due_date # Basic date validation if new_start and new_due and new_start > new_due: violations.append({ "type": "date_order", "message": "Start date cannot be after due date", "start_date": str(new_start), "due_date": str(new_due) }) # Get dependencies where this task is the successor (predecessors) predecessors = db.query(TaskDependency).filter( TaskDependency.successor_id == task.id ).all() for dep in predecessors: pred_task = dep.predecessor if not pred_task: continue lag = timedelta(days=dep.lag_days) violation = None if dep.dependency_type == "FS": # Predecessor must finish before successor starts if pred_task.due_date and new_start: required_start = pred_task.due_date + lag if new_start < required_start: violation = { "type": "dependency_constraint", "dependency_type": "FS", "predecessor_id": pred_task.id, "predecessor_title": pred_task.title, "message": f"Start date must be on or after {required_start.date()} (predecessor due date + {dep.lag_days} days lag)" } elif dep.dependency_type == "SS": # Predecessor must start before successor starts if pred_task.start_date and new_start: required_start = pred_task.start_date + lag if new_start < required_start: violation = { "type": "dependency_constraint", "dependency_type": "SS", "predecessor_id": pred_task.id, "predecessor_title": pred_task.title, "message": f"Start date must be on or after {required_start.date()} (predecessor start date + {dep.lag_days} days lag)" } elif dep.dependency_type == "FF": # Predecessor must finish before successor finishes if pred_task.due_date and new_due: required_due = pred_task.due_date + lag if new_due < required_due: violation = { "type": "dependency_constraint", "dependency_type": "FF", "predecessor_id": pred_task.id, "predecessor_title": pred_task.title, "message": f"Due date must be on or after {required_due.date()} (predecessor due date + {dep.lag_days} days lag)" } elif dep.dependency_type == "SF": # Predecessor must start before successor finishes if pred_task.start_date and new_due: required_due = pred_task.start_date + lag if new_due < required_due: violation = { "type": "dependency_constraint", "dependency_type": "SF", "predecessor_id": pred_task.id, "predecessor_title": pred_task.title, "message": f"Due date must be on or after {required_due.date()} (predecessor start date + {dep.lag_days} days lag)" } if violation: violations.append(violation) # Get dependencies where this task is the predecessor (successors) successors = db.query(TaskDependency).filter( TaskDependency.predecessor_id == task.id ).all() for dep in successors: succ_task = dep.successor if not succ_task: continue lag = timedelta(days=dep.lag_days) violation = None if dep.dependency_type == "FS": # This task must finish before successor starts if new_due and succ_task.start_date: required_due = succ_task.start_date - lag if new_due > required_due: violation = { "type": "dependency_constraint", "dependency_type": "FS", "successor_id": succ_task.id, "successor_title": succ_task.title, "message": f"Due date must be on or before {required_due.date()} (successor start date - {dep.lag_days} days lag)" } elif dep.dependency_type == "SS": # This task must start before successor starts if new_start and succ_task.start_date: required_start = succ_task.start_date - lag if new_start > required_start: violation = { "type": "dependency_constraint", "dependency_type": "SS", "successor_id": succ_task.id, "successor_title": succ_task.title, "message": f"Start date must be on or before {required_start.date()} (successor start date - {dep.lag_days} days lag)" } elif dep.dependency_type == "FF": # This task must finish before successor finishes if new_due and succ_task.due_date: required_due = succ_task.due_date - lag if new_due > required_due: violation = { "type": "dependency_constraint", "dependency_type": "FF", "successor_id": succ_task.id, "successor_title": succ_task.title, "message": f"Due date must be on or before {required_due.date()} (successor due date - {dep.lag_days} days lag)" } elif dep.dependency_type == "SF": # This task must start before successor finishes if new_start and succ_task.due_date: required_start = succ_task.due_date - lag if new_start > required_start: violation = { "type": "dependency_constraint", "dependency_type": "SF", "successor_id": succ_task.id, "successor_title": succ_task.title, "message": f"Start date must be on or before {required_start.date()} (successor due date - {dep.lag_days} days lag)" } if violation: violations.append(violation) return violations @staticmethod def get_all_predecessors(db: Session, task_id: str) -> List[str]: """ Get all transitive predecessors of a task. Uses BFS to find all tasks that this task depends on (directly or indirectly). """ visited: Set[str] = set() queue = [task_id] predecessors = [] while queue: current = queue.pop(0) if current in visited: continue visited.add(current) deps = db.query(TaskDependency).filter( TaskDependency.successor_id == current ).all() for dep in deps: if dep.predecessor_id not in visited: predecessors.append(dep.predecessor_id) queue.append(dep.predecessor_id) return predecessors @staticmethod def get_all_successors(db: Session, task_id: str) -> List[str]: """ Get all transitive successors of a task. Uses BFS to find all tasks that depend on this task (directly or indirectly). """ visited: Set[str] = set() queue = [task_id] successors = [] while queue: current = queue.pop(0) if current in visited: continue visited.add(current) deps = db.query(TaskDependency).filter( TaskDependency.predecessor_id == current ).all() for dep in deps: if dep.successor_id not in visited: successors.append(dep.successor_id) queue.append(dep.successor_id) return successors