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:
1
backend/app/api/admin/__init__.py
Normal file
1
backend/app/api/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Admin API module
|
||||
299
backend/app/api/admin/encryption_keys.py
Normal file
299
backend/app/api/admin/encryption_keys.py
Normal 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}
|
||||
@@ -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}"'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
3
backend/app/api/custom_fields/__init__.py
Normal file
3
backend/app/api/custom_fields/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.custom_fields.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
368
backend/app/api/custom_fields/router.py
Normal file
368
backend/app/api/custom_fields/router.py
Normal 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)
|
||||
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)
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user