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, cycle_path = FormulaService.validate_formula_with_details( field_data.formula, project_id, db ) if not is_valid: detail = {"message": error_msg} if cycle_path: detail["cycle_path"] = cycle_path detail["cycle_description"] = " -> ".join(cycle_path) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=detail, ) # 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, cycle_path = FormulaService.validate_formula_with_details( field_data.formula, field.project_id, db, field_id ) if not is_valid: detail = {"message": error_msg} if cycle_path: detail["cycle_path"] = cycle_path detail["cycle_description"] = " -> ".join(cycle_path) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=detail, ) # 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)