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:
beabigegg
2026-01-05 23:39:12 +08:00
parent 69b81d9241
commit 2d80a8384e
65 changed files with 11045 additions and 82 deletions

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