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 @@
# Admin API module

View File

@@ -0,0 +1,299 @@
"""
Encryption Key Management API (Admin only).
Provides endpoints for:
- Listing encryption keys (without actual key data)
- Creating new encryption keys
- Key rotation
- Checking encryption status
"""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.middleware.auth import get_current_user, require_system_admin
from app.models import User, EncryptionKey, AuditAction
from app.schemas.encryption_key import (
EncryptionKeyResponse,
EncryptionKeyListResponse,
EncryptionKeyCreateResponse,
EncryptionKeyRotateResponse,
EncryptionStatusResponse,
)
from app.services.encryption_service import (
encryption_service,
MasterKeyNotConfiguredError,
)
from app.services.audit_service import AuditService
router = APIRouter(prefix="/api/admin/encryption-keys", tags=["Admin - Encryption Keys"])
def key_to_response(key: EncryptionKey) -> EncryptionKeyResponse:
"""Convert EncryptionKey model to response (without key data)."""
return EncryptionKeyResponse(
id=key.id,
algorithm=key.algorithm,
is_active=key.is_active,
created_at=key.created_at,
rotated_at=key.rotated_at,
)
@router.get("/status", response_model=EncryptionStatusResponse)
async def get_encryption_status(
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Get the current encryption status.
Returns whether encryption is available, active key info, and total key count.
"""
encryption_available = encryption_service.is_encryption_available()
active_key = None
total_keys = 0
if encryption_available:
active_key = db.query(EncryptionKey).filter(
EncryptionKey.is_active == True
).first()
total_keys = db.query(EncryptionKey).count()
message = "Encryption is available" if encryption_available else "Encryption is not configured (ENCRYPTION_MASTER_KEY not set)"
return EncryptionStatusResponse(
encryption_available=encryption_available,
active_key_id=active_key.id if active_key else None,
total_keys=total_keys,
message=message,
)
@router.get("", response_model=EncryptionKeyListResponse)
async def list_encryption_keys(
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
List all encryption keys (without actual key data).
Only accessible by system administrators.
"""
keys = db.query(EncryptionKey).order_by(EncryptionKey.created_at.desc()).all()
return EncryptionKeyListResponse(
keys=[key_to_response(k) for k in keys],
total=len(keys),
)
@router.post("", response_model=EncryptionKeyCreateResponse)
async def create_encryption_key(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Create a new encryption key.
The key is generated, encrypted with the Master Key, and stored.
This does NOT automatically make it the active key - use rotate for that.
"""
if not encryption_service.is_encryption_available():
raise HTTPException(
status_code=400,
detail="Encryption is not configured. Set ENCRYPTION_MASTER_KEY in environment."
)
try:
# Generate new key
raw_key = encryption_service.generate_key()
# Encrypt with master key
encrypted_key = encryption_service.encrypt_key(raw_key)
# Create key record (not active by default)
key = EncryptionKey(
id=str(uuid.uuid4()),
key_data=encrypted_key,
algorithm="AES-256-GCM",
is_active=False,
)
db.add(key)
# Audit log
AuditService.log_event(
db=db,
event_type="encryption.key_create",
resource_type="encryption_key",
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=key.id,
changes=[{"field": "algorithm", "old_value": None, "new_value": "AES-256-GCM"}],
request_metadata=getattr(request.state, "audit_metadata", None),
)
db.commit()
db.refresh(key)
return EncryptionKeyCreateResponse(
id=key.id,
algorithm=key.algorithm,
is_active=key.is_active,
created_at=key.created_at,
message="Encryption key created successfully. Use /rotate to make it active.",
)
except MasterKeyNotConfiguredError:
raise HTTPException(
status_code=400,
detail="Master key is not configured"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=500,
detail=f"Failed to create encryption key: {str(e)}"
)
@router.post("/rotate", response_model=EncryptionKeyRotateResponse)
async def rotate_encryption_key(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Rotate to a new encryption key.
This will:
1. Create a new encryption key
2. Mark the new key as active
3. Mark the old active key as inactive (but keep it for decrypting old files)
After rotation, new file uploads will use the new key.
Old files remain readable using their original keys.
"""
if not encryption_service.is_encryption_available():
raise HTTPException(
status_code=400,
detail="Encryption is not configured. Set ENCRYPTION_MASTER_KEY in environment."
)
try:
# Find current active key
old_active_key = db.query(EncryptionKey).filter(
EncryptionKey.is_active == True
).first()
# Generate new key
raw_key = encryption_service.generate_key()
encrypted_key = encryption_service.encrypt_key(raw_key)
# Create new key as active
new_key = EncryptionKey(
id=str(uuid.uuid4()),
key_data=encrypted_key,
algorithm="AES-256-GCM",
is_active=True,
)
db.add(new_key)
# Deactivate old key if exists
old_key_id = None
if old_active_key:
old_active_key.is_active = False
old_active_key.rotated_at = datetime.utcnow()
old_key_id = old_active_key.id
# Audit log for rotation
AuditService.log_event(
db=db,
event_type="encryption.key_rotate",
resource_type="encryption_key",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=new_key.id,
changes=[
{"field": "rotation", "old_value": old_key_id, "new_value": new_key.id},
{"field": "is_active", "old_value": False, "new_value": True},
],
request_metadata=getattr(request.state, "audit_metadata", None),
)
db.commit()
return EncryptionKeyRotateResponse(
new_key_id=new_key.id,
old_key_id=old_key_id,
message="Key rotation completed successfully. New uploads will use the new key.",
)
except MasterKeyNotConfiguredError:
raise HTTPException(
status_code=400,
detail="Master key is not configured"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=500,
detail=f"Failed to rotate encryption key: {str(e)}"
)
@router.delete("/{key_id}")
async def deactivate_encryption_key(
key_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Deactivate an encryption key.
Note: This does NOT delete the key. Keys are never deleted to ensure
encrypted files can always be decrypted.
If this is the only active key, you must rotate to a new key first.
"""
key = db.query(EncryptionKey).filter(EncryptionKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="Encryption key not found")
if key.is_active:
# Check if there are other active keys
other_active = db.query(EncryptionKey).filter(
EncryptionKey.is_active == True,
EncryptionKey.id != key_id
).first()
if not other_active:
raise HTTPException(
status_code=400,
detail="Cannot deactivate the only active key. Rotate to a new key first."
)
key.is_active = False
key.rotated_at = datetime.utcnow()
# Audit log
AuditService.log_event(
db=db,
event_type="encryption.key_deactivate",
resource_type="encryption_key",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=key.id,
changes=[{"field": "is_active", "old_value": True, "new_value": False}],
request_metadata=getattr(request.state, "audit_metadata", None),
)
db.commit()
return {"detail": "Encryption key deactivated", "id": key_id}

View File

@@ -1,5 +1,7 @@
import uuid
import logging
from datetime import datetime
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
@@ -7,7 +9,7 @@ from typing import Optional
from app.core.database import get_db
from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access
from app.models import User, Task, Project, Attachment, AttachmentVersion, AuditAction
from app.models import User, Task, Project, Attachment, AttachmentVersion, EncryptionKey, AuditAction
from app.schemas.attachment import (
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
AttachmentVersionResponse, VersionHistoryResponse
@@ -15,6 +17,13 @@ from app.schemas.attachment import (
from app.services.file_storage_service import file_storage_service
from app.services.audit_service import AuditService
from app.services.watermark_service import watermark_service
from app.services.encryption_service import (
encryption_service,
MasterKeyNotConfiguredError,
DecryptionError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["attachments"])
@@ -103,6 +112,40 @@ def version_to_response(version: AttachmentVersion) -> AttachmentVersionResponse
)
def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[EncryptionKey]]:
"""
Determine if a file should be encrypted based on project security level.
Returns:
Tuple of (should_encrypt, encryption_key)
"""
# Only encrypt for confidential projects
if project.security_level != "confidential":
return False, None
# Check if encryption is available
if not encryption_service.is_encryption_available():
logger.warning(
f"Project {project.id} is confidential but encryption is not configured. "
"Files will be stored unencrypted."
)
return False, None
# Get active encryption key
active_key = db.query(EncryptionKey).filter(
EncryptionKey.is_active == True
).first()
if not active_key:
logger.warning(
f"Project {project.id} is confidential but no active encryption key exists. "
"Files will be stored unencrypted. Create a key using /api/admin/encryption-keys/rotate"
)
return False, None
return True, active_key
@router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse)
async def upload_attachment(
task_id: str,
@@ -111,10 +154,22 @@ async def upload_attachment(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Upload a file attachment to a task."""
"""
Upload a file attachment to a task.
For confidential projects, files are automatically encrypted using AES-256-GCM.
"""
task = get_task_with_access_check(db, task_id, current_user, require_edit=True)
# Check if attachment with same filename exists (for versioning in Phase 2)
# Get project to check security level
project = db.query(Project).filter(Project.id == task.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Determine if encryption is needed
should_encrypt, encryption_key = should_encrypt_file(project, db)
# Check if attachment with same filename exists (for versioning)
existing = db.query(Attachment).filter(
Attachment.task_id == task_id,
Attachment.original_filename == file.filename,
@@ -122,17 +177,73 @@ async def upload_attachment(
).first()
if existing:
# Phase 2: Create new version
# Create new version for existing attachment
new_version = existing.current_version + 1
# Save file
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=existing.id,
version=new_version
)
if should_encrypt and encryption_key:
# Read and encrypt file content
file_content = await file.read()
await file.seek(0)
try:
# Decrypt the encryption key
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
# Encrypt the file
encrypted_content = encryption_service.encrypt_bytes(file_content, raw_key)
# Create a new UploadFile-like object with encrypted content
encrypted_file = BytesIO(encrypted_content)
encrypted_file.seek(0)
# Save encrypted file using a modified approach
file_path, _, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=existing.id,
version=new_version
)
# Overwrite with encrypted content
with open(file_path, "wb") as f:
f.write(encrypted_content)
file_size = len(encrypted_content)
# Update existing attachment with encryption info
existing.is_encrypted = True
existing.encryption_key_id = encryption_key.id
# Audit log for encryption
AuditService.log_event(
db=db,
event_type="attachment.encrypt",
resource_type="attachment",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=existing.id,
changes=[
{"field": "is_encrypted", "old_value": False, "new_value": True},
{"field": "encryption_key_id", "old_value": None, "new_value": encryption_key.id},
],
request_metadata=getattr(request.state, "audit_metadata", None),
)
except Exception as e:
logger.error(f"Failed to encrypt file for attachment {existing.id}: {e}")
raise HTTPException(
status_code=500,
detail="Failed to encrypt file. Please try again."
)
else:
# Save file without encryption
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=existing.id,
version=new_version
)
# Create version record
version = AttachmentVersion(
@@ -170,15 +281,52 @@ async def upload_attachment(
# Create new attachment
attachment_id = str(uuid.uuid4())
is_encrypted = False
encryption_key_id = None
# Save file
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=attachment_id,
version=1
)
if should_encrypt and encryption_key:
# Read and encrypt file content
file_content = await file.read()
await file.seek(0)
try:
# Decrypt the encryption key
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
# Encrypt the file
encrypted_content = encryption_service.encrypt_bytes(file_content, raw_key)
# Save file first to get path
file_path, _, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=attachment_id,
version=1
)
# Overwrite with encrypted content
with open(file_path, "wb") as f:
f.write(encrypted_content)
file_size = len(encrypted_content)
is_encrypted = True
encryption_key_id = encryption_key.id
except Exception as e:
logger.error(f"Failed to encrypt new file: {e}")
raise HTTPException(
status_code=500,
detail="Failed to encrypt file. Please try again."
)
else:
# Save file without encryption
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=attachment_id,
version=1
)
# Get mime type from file storage validation
extension = file_storage_service.get_extension(file.filename or "")
@@ -193,7 +341,8 @@ async def upload_attachment(
mime_type=mime_type,
file_size=file_size,
current_version=1,
is_encrypted=False,
is_encrypted=is_encrypted,
encryption_key_id=encryption_key_id,
uploaded_by=current_user.id
)
db.add(attachment)
@@ -211,6 +360,10 @@ async def upload_attachment(
db.add(version)
# Audit log
changes = [{"field": "filename", "old_value": None, "new_value": attachment.filename}]
if is_encrypted:
changes.append({"field": "is_encrypted", "old_value": None, "new_value": True})
AuditService.log_event(
db=db,
event_type="attachment.upload",
@@ -218,7 +371,7 @@ async def upload_attachment(
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "filename", "old_value": None, "new_value": attachment.filename}],
changes=changes,
request_metadata=getattr(request.state, "audit_metadata", None)
)
@@ -286,7 +439,11 @@ async def download_attachment(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Download an attachment file with dynamic watermark."""
"""
Download an attachment file with dynamic watermark.
For encrypted files, the file is automatically decrypted before returning.
"""
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False)
# Get version to download
@@ -319,14 +476,69 @@ async def download_attachment(
)
db.commit()
# Read file content
with open(file_path, "rb") as f:
file_bytes = f.read()
# Decrypt if encrypted
if attachment.is_encrypted:
if not attachment.encryption_key_id:
raise HTTPException(
status_code=500,
detail="Encrypted file is missing encryption key reference"
)
encryption_key = db.query(EncryptionKey).filter(
EncryptionKey.id == attachment.encryption_key_id
).first()
if not encryption_key:
raise HTTPException(
status_code=500,
detail="Encryption key not found for this file"
)
try:
# Decrypt the encryption key
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
# Decrypt the file
file_bytes = encryption_service.decrypt_bytes(file_bytes, raw_key)
# Audit log for decryption
AuditService.log_event(
db=db,
event_type="attachment.decrypt",
resource_type="attachment",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "decrypted_for_download", "old_value": None, "new_value": True}],
request_metadata=getattr(request.state, "audit_metadata", None) if request else None,
)
db.commit()
except DecryptionError as e:
logger.error(f"Failed to decrypt attachment {attachment_id}: {e}")
raise HTTPException(
status_code=500,
detail="Failed to decrypt file. The file may be corrupted."
)
except MasterKeyNotConfiguredError:
raise HTTPException(
status_code=500,
detail="Encryption is not configured. Cannot decrypt file."
)
except Exception as e:
logger.error(f"Unexpected error decrypting attachment {attachment_id}: {e}")
raise HTTPException(
status_code=500,
detail="Failed to decrypt file."
)
# Check if watermark should be applied
mime_type = attachment.mime_type or ""
if watermark_service.supports_watermark(mime_type):
try:
# Read the original file
with open(file_path, "rb") as f:
file_bytes = f.read()
# Apply watermark based on file type
if watermark_service.is_supported_image(mime_type):
watermarked_bytes, output_format = watermark_service.add_image_watermark(
@@ -367,19 +579,19 @@ async def download_attachment(
)
except Exception as e:
# If watermarking fails, log the error but still return the original file
# This ensures users can still download files even if watermarking has issues
import logging
logging.getLogger(__name__).warning(
# If watermarking fails, log the error but still return the file
logger.warning(
f"Watermarking failed for attachment {attachment_id}: {str(e)}. "
"Returning original file."
"Returning file without watermark."
)
# Return original file without watermark for unsupported types or on error
return FileResponse(
path=str(file_path),
filename=attachment.original_filename,
media_type=attachment.mime_type
# Return file (decrypted if needed, without watermark for unsupported types)
return Response(
content=file_bytes,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.original_filename}"'
}
)

View File

@@ -0,0 +1,3 @@
from app.api.custom_fields.router import router
__all__ = ["router"]

View File

@@ -0,0 +1,368 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.models import User, Project, CustomField, TaskCustomValue
from app.schemas.custom_field import (
CustomFieldCreate, CustomFieldUpdate, CustomFieldResponse, CustomFieldListResponse
)
from app.middleware.auth import get_current_user, check_project_access, check_project_edit_access
from app.services.formula_service import FormulaService
router = APIRouter(tags=["custom-fields"])
# Maximum custom fields per project
MAX_CUSTOM_FIELDS_PER_PROJECT = 20
def custom_field_to_response(field: CustomField) -> CustomFieldResponse:
"""Convert CustomField model to response schema."""
return CustomFieldResponse(
id=field.id,
project_id=field.project_id,
name=field.name,
field_type=field.field_type,
options=field.options,
formula=field.formula,
is_required=field.is_required,
position=field.position,
created_at=field.created_at,
updated_at=field.updated_at,
)
@router.post("/api/projects/{project_id}/custom-fields", response_model=CustomFieldResponse, status_code=status.HTTP_201_CREATED)
async def create_custom_field(
project_id: str,
field_data: CustomFieldCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a new custom field for a project.
Only project owner or system admin can create custom fields.
Maximum 20 custom fields per 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",
)
if not check_project_edit_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied - only project owner can manage custom fields",
)
# Check custom field count limit
field_count = db.query(CustomField).filter(
CustomField.project_id == project_id
).count()
if field_count >= MAX_CUSTOM_FIELDS_PER_PROJECT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum {MAX_CUSTOM_FIELDS_PER_PROJECT} custom fields per project exceeded",
)
# Check for duplicate name
existing = db.query(CustomField).filter(
CustomField.project_id == project_id,
CustomField.name == field_data.name,
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Custom field with name '{field_data.name}' already exists",
)
# Validate formula if it's a formula field
if field_data.field_type.value == "formula":
if not field_data.formula:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Formula is required for formula fields",
)
is_valid, error_msg = FormulaService.validate_formula(
field_data.formula, project_id, db
)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_msg,
)
# Get next position
max_pos = db.query(CustomField).filter(
CustomField.project_id == project_id
).order_by(CustomField.position.desc()).first()
next_position = (max_pos.position + 1) if max_pos else 0
# Create the custom field
field = CustomField(
id=str(uuid.uuid4()),
project_id=project_id,
name=field_data.name,
field_type=field_data.field_type.value,
options=field_data.options,
formula=field_data.formula,
is_required=field_data.is_required,
position=next_position,
)
db.add(field)
db.commit()
db.refresh(field)
return custom_field_to_response(field)
@router.get("/api/projects/{project_id}/custom-fields", response_model=CustomFieldListResponse)
async def list_custom_fields(
project_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all custom fields for a 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",
)
if not check_project_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
fields = db.query(CustomField).filter(
CustomField.project_id == project_id
).order_by(CustomField.position).all()
return CustomFieldListResponse(
fields=[custom_field_to_response(f) for f in fields],
total=len(fields),
)
@router.get("/api/custom-fields/{field_id}", response_model=CustomFieldResponse)
async def get_custom_field(
field_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get a specific custom field by ID.
"""
field = db.query(CustomField).filter(CustomField.id == field_id).first()
if not field:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom field not found",
)
project = field.project
if not check_project_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
return custom_field_to_response(field)
@router.put("/api/custom-fields/{field_id}", response_model=CustomFieldResponse)
async def update_custom_field(
field_id: str,
field_data: CustomFieldUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a custom field.
Only project owner or system admin can update custom fields.
Note: field_type cannot be changed after creation.
"""
field = db.query(CustomField).filter(CustomField.id == field_id).first()
if not field:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom field not found",
)
project = field.project
if not check_project_edit_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
# Check for duplicate name if name is being updated
if field_data.name is not None and field_data.name != field.name:
existing = db.query(CustomField).filter(
CustomField.project_id == field.project_id,
CustomField.name == field_data.name,
CustomField.id != field_id,
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Custom field with name '{field_data.name}' already exists",
)
# Validate formula if updating formula field
if field.field_type == "formula" and field_data.formula is not None:
is_valid, error_msg = FormulaService.validate_formula(
field_data.formula, field.project_id, db, field_id
)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_msg,
)
# Validate options if updating dropdown field
if field.field_type == "dropdown" and field_data.options is not None:
if len(field_data.options) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Dropdown fields must have at least one option",
)
if len(field_data.options) > 50:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Dropdown fields can have at most 50 options",
)
# Update fields
if field_data.name is not None:
field.name = field_data.name
if field_data.options is not None and field.field_type == "dropdown":
field.options = field_data.options
if field_data.formula is not None and field.field_type == "formula":
field.formula = field_data.formula
if field_data.is_required is not None:
field.is_required = field_data.is_required
db.commit()
db.refresh(field)
return custom_field_to_response(field)
@router.delete("/api/custom-fields/{field_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_custom_field(
field_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Delete a custom field.
Only project owner or system admin can delete custom fields.
This will also delete all stored values for this field.
"""
field = db.query(CustomField).filter(CustomField.id == field_id).first()
if not field:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom field not found",
)
project = field.project
if not check_project_edit_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
# Check if any formula fields reference this field
if field.field_type != "formula":
formula_fields = db.query(CustomField).filter(
CustomField.project_id == field.project_id,
CustomField.field_type == "formula",
).all()
for formula_field in formula_fields:
if formula_field.formula:
references = FormulaService.extract_field_references(formula_field.formula)
if field.name in references:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete: field is referenced by formula field '{formula_field.name}'",
)
# Delete the field (cascade will delete associated values)
db.delete(field)
db.commit()
@router.patch("/api/custom-fields/{field_id}/position", response_model=CustomFieldResponse)
async def update_custom_field_position(
field_id: str,
position: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a custom field's position (for reordering).
"""
field = db.query(CustomField).filter(CustomField.id == field_id).first()
if not field:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom field not found",
)
project = field.project
if not check_project_edit_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
old_position = field.position
if position == old_position:
return custom_field_to_response(field)
# Reorder other fields
if position > old_position:
# Moving down: shift fields between old and new position up
db.query(CustomField).filter(
CustomField.project_id == field.project_id,
CustomField.position > old_position,
CustomField.position <= position,
).update({CustomField.position: CustomField.position - 1})
else:
# Moving up: shift fields between new and old position down
db.query(CustomField).filter(
CustomField.project_id == field.project_id,
CustomField.position >= position,
CustomField.position < old_position,
).update({CustomField.position: CustomField.position + 1})
field.position = position
db.commit()
db.refresh(field)
return custom_field_to_response(field)

View File

@@ -0,0 +1,3 @@
from app.api.task_dependencies.router import router
__all__ = ["router"]

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

View File

@@ -10,7 +10,7 @@ from app.core.redis_pubsub import publish_task_event
from app.models import User, Project, Task, TaskStatus, AuditAction, Blocker
from app.schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse,
TaskStatusUpdate, TaskAssignUpdate
TaskStatusUpdate, TaskAssignUpdate, CustomValueResponse
)
from app.middleware.auth import (
get_current_user, check_project_access, check_task_access, check_task_edit_access
@@ -19,6 +19,8 @@ from app.middleware.audit import get_audit_metadata
from app.services.audit_service import AuditService
from app.services.trigger_service import TriggerService
from app.services.workload_cache import invalidate_user_workload_cache
from app.services.custom_value_service import CustomValueService
from app.services.dependency_service import DependencyService
logger = logging.getLogger(__name__)
@@ -40,13 +42,18 @@ def get_task_depth(db: Session, task: Task) -> int:
return depth
def task_to_response(task: Task) -> TaskWithDetails:
def task_to_response(task: Task, db: Session = None, include_custom_values: bool = False) -> TaskWithDetails:
"""Convert a Task model to TaskWithDetails response."""
# Count only non-deleted subtasks
subtask_count = 0
if task.subtasks:
subtask_count = sum(1 for st in task.subtasks if not st.is_deleted)
# Get custom values if requested
custom_values = None
if include_custom_values and db:
custom_values = CustomValueService.get_custom_values_for_task(db, task)
return TaskWithDetails(
id=task.id,
project_id=task.project_id,
@@ -56,6 +63,7 @@ def task_to_response(task: Task) -> TaskWithDetails:
priority=task.priority,
original_estimate=task.original_estimate,
time_spent=task.time_spent,
start_date=task.start_date,
due_date=task.due_date,
assignee_id=task.assignee_id,
status_id=task.status_id,
@@ -69,6 +77,7 @@ def task_to_response(task: Task) -> TaskWithDetails:
status_color=task.status.color if task.status else None,
creator_name=task.creator.name if task.creator else None,
subtask_count=subtask_count,
custom_values=custom_values,
)
@@ -78,12 +87,24 @@ async def list_tasks(
parent_task_id: Optional[str] = Query(None, description="Filter by parent task"),
status_id: Optional[str] = Query(None, description="Filter by status"),
assignee_id: Optional[str] = Query(None, description="Filter by assignee"),
due_after: Optional[datetime] = Query(None, description="Filter tasks with due_date >= this value (for calendar view)"),
due_before: Optional[datetime] = Query(None, description="Filter tasks with due_date <= this value (for calendar view)"),
include_deleted: bool = Query(False, description="Include deleted tasks (admin only)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all tasks in a project.
Supports filtering by:
- parent_task_id: Filter by parent task (empty string for root tasks only)
- status_id: Filter by task status
- assignee_id: Filter by assigned user
- due_after: Filter tasks with due_date >= this value (ISO 8601 datetime)
- due_before: Filter tasks with due_date <= this value (ISO 8601 datetime)
The due_after and due_before parameters are useful for calendar view
to fetch tasks within a specific date range.
"""
project = db.query(Project).filter(Project.id == project_id).first()
@@ -124,10 +145,17 @@ async def list_tasks(
if assignee_id:
query = query.filter(Task.assignee_id == assignee_id)
# Date range filter for calendar view
if due_after:
query = query.filter(Task.due_date >= due_after)
if due_before:
query = query.filter(Task.due_date <= due_before)
tasks = query.order_by(Task.position, Task.created_at).all()
return TaskListResponse(
tasks=[task_to_response(t) for t in tasks],
tasks=[task_to_response(t, db=db, include_custom_values=True) for t in tasks],
total=len(tasks),
)
@@ -204,6 +232,25 @@ async def create_task(
).order_by(Task.position.desc()).first()
next_position = (max_pos_result.position + 1) if max_pos_result else 0
# Validate required custom fields
if task_data.custom_values:
missing_fields = CustomValueService.validate_required_fields(
db, project_id, task_data.custom_values
)
if missing_fields:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Missing required custom fields: {', '.join(missing_fields)}",
)
# Validate start_date <= due_date
if task_data.start_date and task_data.due_date:
if task_data.start_date > task_data.due_date:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Start date cannot be after due date",
)
task = Task(
id=str(uuid.uuid4()),
project_id=project_id,
@@ -212,6 +259,7 @@ async def create_task(
description=task_data.description,
priority=task_data.priority.value if task_data.priority else "medium",
original_estimate=task_data.original_estimate,
start_date=task_data.start_date,
due_date=task_data.due_date,
assignee_id=task_data.assignee_id,
status_id=task_data.status_id,
@@ -220,6 +268,17 @@ async def create_task(
)
db.add(task)
db.flush() # Flush to get task.id for custom values
# Save custom values
if task_data.custom_values:
try:
CustomValueService.save_custom_values(db, task, task_data.custom_values)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Audit log
AuditService.log_event(
@@ -256,6 +315,7 @@ async def create_task(
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee_name": task.assignee.name if task.assignee else None,
"priority": task.priority,
"start_date": str(task.start_date) if task.start_date else None,
"due_date": str(task.due_date) if task.due_date else None,
"time_estimate": task.original_estimate,
"original_estimate": task.original_estimate,
@@ -303,7 +363,7 @@ async def get_task(
detail="Access denied",
)
return task_to_response(task)
return task_to_response(task, db, include_custom_values=True)
@router.patch("/api/tasks/{task_id}", response_model=TaskResponse)
@@ -336,13 +396,42 @@ async def update_task(
"title": task.title,
"description": task.description,
"priority": task.priority,
"start_date": task.start_date,
"due_date": task.due_date,
"original_estimate": task.original_estimate,
"time_spent": task.time_spent,
}
# Update fields
# Update fields (exclude custom_values, handle separately)
update_data = task_data.model_dump(exclude_unset=True)
custom_values_data = update_data.pop("custom_values", None)
# Get the proposed start_date and due_date for validation
new_start_date = update_data.get("start_date", task.start_date)
new_due_date = update_data.get("due_date", task.due_date)
# Validate start_date <= due_date
if new_start_date and new_due_date:
if new_start_date > new_due_date:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Start date cannot be after due date",
)
# Validate date constraints against dependencies
if "start_date" in update_data or "due_date" in update_data:
violations = DependencyService.validate_date_constraints(
task, new_start_date, new_due_date, db
)
if violations:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": "Date change violates dependency constraints",
"violations": violations
}
)
for field, value in update_data.items():
if field == "priority" and value:
setattr(task, field, value.value)
@@ -354,6 +443,7 @@ async def update_task(
"title": task.title,
"description": task.description,
"priority": task.priority,
"start_date": task.start_date,
"due_date": task.due_date,
"original_estimate": task.original_estimate,
"time_spent": task.time_spent,
@@ -377,6 +467,18 @@ async def update_task(
if "priority" in update_data:
TriggerService.evaluate_triggers(db, task, old_values, new_values, current_user)
# Handle custom values update
if custom_values_data:
try:
from app.schemas.task import CustomValueInput
custom_values = [CustomValueInput(**cv) for cv in custom_values_data]
CustomValueService.save_custom_values(db, task, custom_values)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
db.commit()
db.refresh(task)
@@ -400,6 +502,7 @@ async def update_task(
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee_name": task.assignee.name if task.assignee else None,
"priority": task.priority,
"start_date": str(task.start_date) if task.start_date else None,
"due_date": str(task.due_date) if task.due_date else None,
"time_estimate": task.original_estimate,
"original_estimate": task.original_estimate,