- 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>
432 lines
13 KiB
Python
432 lines
13 KiB
Python
"""
|
|
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.models import User, Task, TaskDependency, AuditAction
|
|
from app.schemas.task_dependency import (
|
|
TaskDependencyCreate,
|
|
TaskDependencyUpdate,
|
|
TaskDependencyResponse,
|
|
TaskDependencyListResponse,
|
|
TaskInfo
|
|
)
|
|
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)
|
|
)
|