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>
This commit is contained in:
424
backend/app/services/dependency_service.py
Normal file
424
backend/app/services/dependency_service.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user