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:
3
backend/app/api/task_dependencies/__init__.py
Normal file
3
backend/app/api/task_dependencies/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.task_dependencies.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
431
backend/app/api/task_dependencies/router.py
Normal file
431
backend/app/api/task_dependencies/router.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
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)
|
||||
)
|
||||
Reference in New Issue
Block a user