Files
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

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)
)