- 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>
369 lines
12 KiB
Python
369 lines
12 KiB
Python
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)
|