"""API routes for CustomFieldDefinition CRUD — generic across entities. Paket 4 (#357): applies the W4b pattern — entity validation (422) and owner-module read check (403) via ENTITY_PLUGIN_OWNERS, central helpers from entity_permission_service, and the {items, total} list shape all frontend consumers read. """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user, require_permission from app.schemas.custom_field_definition import ( CustomFieldDefinitionCreate, CustomFieldDefinitionListResponse, CustomFieldDefinitionResponse, CustomFieldDefinitionUpdate, ) from app.services import custom_field_service from app.services.entity_permission_service import ( check_entity_read_permission, validate_entity_type, ) router = APIRouter(prefix="/api/v1/custom-fields", tags=["custom-fields-definitions"]) @router.get( "/definitions", response_model=CustomFieldDefinitionListResponse, dependencies=[Depends(require_permission("custom_fields:read"))], ) async def list_definitions( entity: str | None = Query(None, description="Filter by entity type (e.g. 'contact', 'company')"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List all active custom field definitions for the current tenant. Returns ``{items, total}`` — the shape the frontend API client and all six consumers (CustomFields page, ContactsList, SortPanel, GroupPanel, FilterPanel) read. """ tenant_id = uuid.UUID(current_user["tenant_id"]) if entity: validate_entity_type(entity) check_entity_read_permission(current_user, entity) definitions = await custom_field_service.list_definitions( db, tenant_id, entity=entity, user_id=uuid.UUID(current_user["user_id"]), is_system_admin=current_user.get("is_system_admin", False), ) return CustomFieldDefinitionListResponse( items=[CustomFieldDefinitionResponse.model_validate(d) for d in definitions], total=len(definitions), ) @router.post( "/definitions", response_model=CustomFieldDefinitionResponse, status_code=201, dependencies=[Depends(require_permission("custom_fields:write"))], ) async def create_definition( body: CustomFieldDefinitionCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Create a new custom field definition. W4b: the entity must be a registered entity type (422) and the user needs the owning module's read permission (e.g. contacts:read) on top of custom_fields:write — writing definitions for modules you cannot even read would bypass module isolation. """ validate_entity_type(body.entity) check_entity_read_permission(current_user, body.entity) tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) definition = await custom_field_service.create_definition( db, tenant_id, user_id, body.model_dump() ) return definition @router.patch( "/definitions/{definition_id}", response_model=CustomFieldDefinitionResponse, dependencies=[Depends(require_permission("custom_fields:write"))], ) async def update_definition( definition_id: str, body: CustomFieldDefinitionUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update an existing custom field definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) try: def_id = uuid.UUID(definition_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid definition_id", "code": "invalid_id"}) from None # Filter out None values from the update body update_data = {k: v for k, v in body.model_dump().items() if v is not None} if not update_data: raise HTTPException(400, detail={"detail": "No fields to update", "code": "no_updates"}) try: definition = await custom_field_service.update_definition( db, tenant_id, def_id, update_data, user_id=user_id, is_system_admin=is_admin ) except PermissionError as e: raise HTTPException(status_code=403, detail={"detail": str(e), "code": "forbidden"}) from e if definition is None: raise HTTPException(404, detail={"detail": "Definition not found", "code": "not_found"}) # W4b: changing a definition still requires read access to its entity module check_entity_read_permission(current_user, definition.entity) return definition @router.delete( "/definitions/{definition_id}", status_code=204, dependencies=[Depends(require_permission("custom_fields:write"))], ) async def delete_definition( definition_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Delete a custom field definition.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) try: def_id = uuid.UUID(definition_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid definition_id", "code": "invalid_id"}) from None try: deleted = await custom_field_service.delete_definition( db, tenant_id, def_id, user_id=user_id, is_system_admin=is_admin ) except PermissionError as e: raise HTTPException(status_code=403, detail={"detail": str(e), "code": "forbidden"}) from e if not deleted: raise HTTPException(404, detail={"detail": "Definition not found", "code": "not_found"}) return None