## 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>
618 lines
19 KiB
Python
618 lines
19 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.core.rate_limiter import limiter
|
|
from app.core.config import settings
|
|
from app.models import User, Task, TaskDependency, AuditAction
|
|
from app.schemas.task_dependency import (
|
|
TaskDependencyCreate,
|
|
TaskDependencyUpdate,
|
|
TaskDependencyResponse,
|
|
TaskDependencyListResponse,
|
|
TaskInfo,
|
|
BulkDependencyCreate,
|
|
BulkDependencyValidationResult,
|
|
BulkDependencyCreateResponse,
|
|
)
|
|
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)
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/api/projects/{project_id}/dependencies/validate",
|
|
response_model=BulkDependencyValidationResult
|
|
)
|
|
async def validate_bulk_dependencies(
|
|
project_id: str,
|
|
bulk_data: BulkDependencyCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Validate a batch of dependencies without creating them.
|
|
|
|
This endpoint checks for:
|
|
- Self-references
|
|
- Cross-project dependencies
|
|
- Duplicate dependencies
|
|
- Circular dependencies (including cycles that would be created by the batch)
|
|
|
|
Returns validation results without modifying the database.
|
|
"""
|
|
from app.models import Project
|
|
from app.middleware.auth import check_project_access
|
|
|
|
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"
|
|
)
|
|
|
|
if not check_project_access(current_user, project):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied"
|
|
)
|
|
|
|
# Convert to tuple format for validation
|
|
dependencies = [
|
|
(dep.predecessor_id, dep.successor_id)
|
|
for dep in bulk_data.dependencies
|
|
]
|
|
|
|
errors = DependencyService.validate_bulk_dependencies(db, dependencies, project_id)
|
|
|
|
return BulkDependencyValidationResult(
|
|
valid=len(errors) == 0,
|
|
errors=errors
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/api/projects/{project_id}/dependencies/bulk",
|
|
response_model=BulkDependencyCreateResponse,
|
|
status_code=status.HTTP_201_CREATED
|
|
)
|
|
@limiter.limit(settings.RATE_LIMIT_HEAVY)
|
|
async def create_bulk_dependencies(
|
|
request: Request,
|
|
project_id: str,
|
|
bulk_data: BulkDependencyCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Create multiple dependencies at once.
|
|
|
|
This endpoint:
|
|
1. Validates all dependencies together for cycle detection
|
|
2. Creates valid dependencies
|
|
3. Returns both created dependencies and any failures
|
|
|
|
Cycle detection considers all dependencies in the batch together,
|
|
so cycles that would only appear when all dependencies are added
|
|
will be caught.
|
|
|
|
Rate limited: 5 requests per minute (heavy tier).
|
|
"""
|
|
from app.models import Project
|
|
from app.middleware.auth import check_project_edit_access
|
|
|
|
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"
|
|
)
|
|
|
|
if not check_project_edit_access(current_user, project):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Permission denied"
|
|
)
|
|
|
|
# Convert to tuple format for validation
|
|
dependencies_to_validate = [
|
|
(dep.predecessor_id, dep.successor_id)
|
|
for dep in bulk_data.dependencies
|
|
]
|
|
|
|
# Validate all dependencies together
|
|
errors = DependencyService.validate_bulk_dependencies(
|
|
db, dependencies_to_validate, project_id
|
|
)
|
|
|
|
# Build a set of failed dependency pairs for quick lookup
|
|
failed_pairs = set()
|
|
for error in errors:
|
|
pair = (error.get("predecessor_id"), error.get("successor_id"))
|
|
failed_pairs.add(pair)
|
|
|
|
created_dependencies = []
|
|
failed_items = errors # Include validation errors
|
|
|
|
# Create dependencies that passed validation
|
|
for dep_data in bulk_data.dependencies:
|
|
pair = (dep_data.predecessor_id, dep_data.successor_id)
|
|
if pair in failed_pairs:
|
|
continue
|
|
|
|
# Additional check: verify dependency limit for successor
|
|
current_count = db.query(TaskDependency).filter(
|
|
TaskDependency.successor_id == dep_data.successor_id
|
|
).count()
|
|
|
|
if current_count >= DependencyService.MAX_DIRECT_DEPENDENCIES:
|
|
failed_items.append({
|
|
"error_type": "limit_exceeded",
|
|
"predecessor_id": dep_data.predecessor_id,
|
|
"successor_id": dep_data.successor_id,
|
|
"message": f"Successor task already has {DependencyService.MAX_DIRECT_DEPENDENCIES} dependencies"
|
|
})
|
|
continue
|
|
|
|
# Create the dependency
|
|
dependency = TaskDependency(
|
|
id=str(uuid.uuid4()),
|
|
predecessor_id=dep_data.predecessor_id,
|
|
successor_id=dep_data.successor_id,
|
|
dependency_type=dep_data.dependency_type.value,
|
|
lag_days=dep_data.lag_days
|
|
)
|
|
|
|
db.add(dependency)
|
|
created_dependencies.append(dependency)
|
|
|
|
# Audit log for each created dependency
|
|
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()
|
|
|
|
# Refresh created dependencies to get relationships
|
|
for dep in created_dependencies:
|
|
db.refresh(dep)
|
|
|
|
return BulkDependencyCreateResponse(
|
|
created=[dependency_to_response(d) for d in created_dependencies],
|
|
failed=failed_items,
|
|
total_created=len(created_dependencies),
|
|
total_failed=len(failed_items)
|
|
)
|