Files
PROJECT-CONTORL/backend/app/services/dependency_service.py
beabigegg 3bdc6ff1c9 feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements
## Security Enhancements (P0)
- Add input validation with max_length and numeric range constraints
- Implement WebSocket token authentication via first message
- Add path traversal prevention in file storage service

## Permission Enhancements (P0)
- Add project member management for cross-department access
- Implement is_department_manager flag for workload visibility

## Cycle Detection (P0)
- Add DFS-based cycle detection for task dependencies
- Add formula field circular reference detection
- Display user-friendly cycle path visualization

## Concurrency & Reliability (P1)
- Implement optimistic locking with version field (409 Conflict on mismatch)
- Add trigger retry mechanism with exponential backoff (1s, 2s, 4s)
- Implement cascade restore for soft-deleted tasks

## Rate Limiting (P1)
- Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min)
- Apply rate limits to tasks, reports, attachments, and comments

## Frontend Improvements (P1)
- Add responsive sidebar with hamburger menu for mobile
- Improve touch-friendly UI with proper tap target sizes
- Complete i18n translations for all components

## Backend Reliability (P2)
- Configure database connection pool (size=10, overflow=20)
- Add Redis fallback mechanism with message queue
- Add blocker check before task deletion

## API Enhancements (P3)
- Add standardized response wrapper utility
- Add /health/ready and /health/live endpoints
- Implement project templates with status/field copying

## Tests Added
- test_input_validation.py - Schema and path traversal tests
- test_concurrency_reliability.py - Optimistic locking and retry tests
- test_backend_reliability.py - Connection pool and Redis tests
- test_api_enhancements.py - Health check and template tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:13:43 +08:00

702 lines
26 KiB
Python

"""
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
- Bulk dependency operations with cycle detection
"""
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 CycleDetectionResult:
"""Result of cycle detection with detailed path information."""
def __init__(
self,
has_cycle: bool,
cycle_path: Optional[List[str]] = None,
cycle_task_titles: Optional[List[str]] = None
):
self.has_cycle = has_cycle
self.cycle_path = cycle_path or []
self.cycle_task_titles = cycle_task_titles or []
def get_cycle_description(self) -> str:
"""Get a human-readable description of the cycle."""
if not self.has_cycle or not self.cycle_task_titles:
return ""
# Format: Task A -> Task B -> Task C -> Task A
return " -> ".join(self.cycle_task_titles)
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
"""
result = DependencyService.detect_circular_dependency_detailed(
db, predecessor_id, successor_id, project_id
)
return result.cycle_path if result.has_cycle else None
@staticmethod
def detect_circular_dependency_detailed(
db: Session,
predecessor_id: str,
successor_id: str,
project_id: str,
additional_edges: Optional[List[Tuple[str, str]]] = None
) -> CycleDetectionResult:
"""
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
additional_edges: Optional list of additional (predecessor_id, successor_id)
edges to consider (for bulk operations)
Returns:
CycleDetectionResult with detailed cycle information
"""
# 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)
# Add any additional edges for bulk operations
if additional_edges:
for pred_id, succ_id in additional_edges:
graph[succ_id].append(pred_id)
# Build task title map for readable error messages
task_ids_in_graph = set()
for succ_id, pred_ids in graph.items():
task_ids_in_graph.add(succ_id)
task_ids_in_graph.update(pred_ids)
tasks = db.query(Task).filter(Task.id.in_(task_ids_in_graph)).all()
task_title_map: Dict[str, str] = {t.id: t.title for t in tasks}
# 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
cycle_path = dfs(successor_id)
if cycle_path:
# Build task titles for the cycle
cycle_titles = [task_title_map.get(task_id, task_id) for task_id in cycle_path]
return CycleDetectionResult(
has_cycle=True,
cycle_path=cycle_path,
cycle_task_titles=cycle_titles
)
return CycleDetectionResult(has_cycle=False)
@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_result = DependencyService.detect_circular_dependency_detailed(
db, predecessor_id, successor_id, predecessor.project_id
)
if cycle_result.has_cycle:
raise DependencyValidationError(
error_type="circular",
message=f"Adding this dependency would create a circular reference: {cycle_result.get_cycle_description()}",
details={
"cycle": cycle_result.cycle_path,
"cycle_description": cycle_result.get_cycle_description(),
"cycle_task_titles": cycle_result.cycle_task_titles
}
)
@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
@staticmethod
def validate_bulk_dependencies(
db: Session,
dependencies: List[Tuple[str, str]],
project_id: str
) -> List[Dict[str, Any]]:
"""
Validate a batch of dependencies for cycle detection.
This method validates multiple dependencies together to detect cycles
that would only appear when all dependencies are added together.
Args:
db: Database session
dependencies: List of (predecessor_id, successor_id) tuples
project_id: Project ID to scope the query
Returns:
List of validation errors (empty if all valid)
"""
errors: List[Dict[str, Any]] = []
if not dependencies:
return errors
# First, validate each dependency individually for basic checks
for predecessor_id, successor_id in dependencies:
# Check self-reference
if predecessor_id == successor_id:
errors.append({
"error_type": "self_reference",
"predecessor_id": predecessor_id,
"successor_id": successor_id,
"message": "A task cannot depend on itself"
})
continue
# Get tasks to validate project membership
predecessor = db.query(Task).filter(Task.id == predecessor_id).first()
successor = db.query(Task).filter(Task.id == successor_id).first()
if not predecessor:
errors.append({
"error_type": "not_found",
"predecessor_id": predecessor_id,
"successor_id": successor_id,
"message": f"Predecessor task not found: {predecessor_id}"
})
continue
if not successor:
errors.append({
"error_type": "not_found",
"predecessor_id": predecessor_id,
"successor_id": successor_id,
"message": f"Successor task not found: {successor_id}"
})
continue
if predecessor.project_id != project_id or successor.project_id != project_id:
errors.append({
"error_type": "cross_project",
"predecessor_id": predecessor_id,
"successor_id": successor_id,
"message": "All tasks must be in the same project"
})
continue
# Check for duplicates within the batch
existing = db.query(TaskDependency).filter(
TaskDependency.predecessor_id == predecessor_id,
TaskDependency.successor_id == successor_id
).first()
if existing:
errors.append({
"error_type": "duplicate",
"predecessor_id": predecessor_id,
"successor_id": successor_id,
"message": "This dependency already exists"
})
# If there are basic validation errors, return them first
if errors:
return errors
# Now check for cycles considering all dependencies together
# Build the graph incrementally and check for cycles
accumulated_edges: List[Tuple[str, str]] = []
for predecessor_id, successor_id in dependencies:
# Check if adding this edge (plus all previously accumulated edges)
# would create a cycle
cycle_result = DependencyService.detect_circular_dependency_detailed(
db,
predecessor_id,
successor_id,
project_id,
additional_edges=accumulated_edges
)
if cycle_result.has_cycle:
errors.append({
"error_type": "circular",
"predecessor_id": predecessor_id,
"successor_id": successor_id,
"message": f"Adding this dependency would create a circular reference: {cycle_result.get_cycle_description()}",
"cycle": cycle_result.cycle_path,
"cycle_description": cycle_result.get_cycle_description(),
"cycle_task_titles": cycle_result.cycle_task_titles
})
else:
# Add this edge to accumulated edges for subsequent checks
accumulated_edges.append((predecessor_id, successor_id))
return errors
@staticmethod
def detect_cycles_in_graph(
db: Session,
project_id: str
) -> List[CycleDetectionResult]:
"""
Detect all cycles in the existing dependency graph for a project.
This is useful for auditing or cleanup operations.
Args:
db: Database session
project_id: Project ID to check
Returns:
List of CycleDetectionResult for each cycle found
"""
cycles: List[CycleDetectionResult] = []
# Get all dependencies for the project
dependencies = db.query(TaskDependency).join(
Task, TaskDependency.successor_id == Task.id
).filter(Task.project_id == project_id).all()
if not dependencies:
return cycles
# Build the graph
graph: Dict[str, List[str]] = defaultdict(list)
for dep in dependencies:
graph[dep.successor_id].append(dep.predecessor_id)
# Get task titles
task_ids = set()
for succ_id, pred_ids in graph.items():
task_ids.add(succ_id)
task_ids.update(pred_ids)
tasks = db.query(Task).filter(Task.id.in_(task_ids)).all()
task_title_map: Dict[str, str] = {t.id: t.title for t in tasks}
# Find all cycles using DFS
visited: Set[str] = set()
found_cycles: Set[Tuple[str, ...]] = set()
def find_cycles_dfs(node: str, path: List[str], in_path: Set[str]):
"""DFS to find all cycles."""
if node in in_path:
# Found a cycle
cycle_start = path.index(node)
cycle = tuple(sorted(path[cycle_start:])) # Normalize for dedup
if cycle not in found_cycles:
found_cycles.add(cycle)
actual_cycle = path[cycle_start:] + [node]
cycle_titles = [task_title_map.get(tid, tid) for tid in actual_cycle]
cycles.append(CycleDetectionResult(
has_cycle=True,
cycle_path=actual_cycle,
cycle_task_titles=cycle_titles
))
return
if node in visited:
return
visited.add(node)
in_path.add(node)
path.append(node)
for neighbor in graph.get(node, []):
find_cycles_dfs(neighbor, path.copy(), in_path.copy())
path.pop()
in_path.remove(node)
# Start DFS from all nodes
for start_node in graph.keys():
if start_node not in visited:
find_cycles_dfs(start_node, [], set())
return cycles