Files
PROJECT-CONTORL/backend/app/services/dependency_service.py
beabigegg 2d80a8384e feat: implement custom fields, gantt view, calendar view, and file encryption
- Custom Fields (FEAT-001):
  - CustomField and TaskCustomValue models with formula support
  - CRUD API for custom field management
  - Formula engine for calculated fields
  - Frontend: CustomFieldEditor, CustomFieldInput, ProjectSettings page
  - Task list API now includes custom_values
  - KanbanBoard displays custom field values

- Gantt View (FEAT-003):
  - TaskDependency model with FS/SS/FF/SF dependency types
  - Dependency CRUD API with cycle detection
  - start_date field added to tasks
  - GanttChart component with Frappe Gantt integration
  - Dependency type selector in UI

- Calendar View (FEAT-004):
  - CalendarView component with FullCalendar integration
  - Date range filtering API for tasks
  - Drag-and-drop date updates
  - View mode switching in Tasks page

- File Encryption (FEAT-010):
  - AES-256-GCM encryption service
  - EncryptionKey model with key rotation support
  - Admin API for key management
  - Encrypted upload/download for confidential projects

- Migrations: 011 (custom fields), 012 (encryption keys), 013 (task dependencies)
- Updated issues.md with completion status

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 23:39:12 +08:00

425 lines
16 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
"""
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