"""Custom fields routes — merge plugin definitions with stored values.""" from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Body from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user, require_permission from app.models.contact import Contact from app.models.custom_field_definition import CustomFieldDefinition from app.plugins.registry import get_registry router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"]) class CustomFieldUpdateRequest(BaseModel): """Request body for updating custom field values.""" values: dict[str, Any] = {} async def _collect_custom_field_definitions( db: AsyncSession, tenant_id: uuid.UUID, entity: str = "contact", ) -> list[dict[str, Any]]: """Collect all custom field definitions from plugin manifests and DB. DB-stored definitions override plugin definitions with the same name. """ definitions: list[dict[str, Any]] = [] seen_names: set[str] = set() # 1. Collect from active plugin manifests registry = get_registry() for plugin in registry._plugins.values(): manifest = plugin.manifest for cf in manifest.custom_fields: if cf.entity != entity: continue if cf.name in seen_names: continue seen_names.add(cf.name) definitions.append( { "name": cf.name, "label": cf.label, "label_key": cf.label_key, "field_type": cf.field_type, "options": cf.options, "default_value": cf.default_value, "required": cf.required, "entity": cf.entity, "plugin": manifest.name, } ) # 2. Collect from DB (user-defined custom field definitions) stmt = select(CustomFieldDefinition).where( CustomFieldDefinition.tenant_id == tenant_id, CustomFieldDefinition.entity == entity, CustomFieldDefinition.is_active == True, # noqa: E712 ).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name) result = await db.execute(stmt) db_definitions = result.scalars().all() for d in db_definitions: if d.name in seen_names: # DB definition overrides plugin definition — replace it definitions = [x for x in definitions if x["name"] != d.name] else: seen_names.add(d.name) definitions.append( { "name": d.name, "label": d.label, "label_key": "", "field_type": d.field_type, "options": d.options or [], "default_value": d.default_value, "required": d.required, "entity": d.entity, "plugin": "user_defined", } ) return definitions async def _merge_definitions_with_values( definitions: list[dict[str, Any]], stored: dict[str, Any] | None ) -> list[dict[str, Any]]: """Merge field definitions with stored values, applying defaults.""" stored = stored or {} result: list[dict[str, Any]] = [] for d in definitions: name = d["name"] value = stored.get(name, d.get("default_value")) entry = {**d, "value": value} result.append(entry) return result @router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))]) async def get_custom_fields( contact_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get all custom fields for a contact (merged definitions + stored values).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: cid = uuid.UUID(contact_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None result = await db.execute( select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id) ) contact = result.scalar_one_or_none() if contact is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) definitions = await _collect_custom_field_definitions(db, tenant_id, "contact") merged = await _merge_definitions_with_values(definitions, contact.custom) return {"fields": merged} @router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))]) async def update_custom_fields( contact_id: str, body: CustomFieldUpdateRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update custom field values for a contact (stored in contacts.custom JSONB).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: cid = uuid.UUID(contact_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None result = await db.execute( select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id) ) contact = result.scalar_one_or_none() if contact is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) # Validate against definitions definitions = await _collect_custom_field_definitions(db, tenant_id, "contact") def_map = {d["name"]: d for d in definitions} current_custom = dict(contact.custom or {}) for name, value in body.values.items(): if name not in def_map: raise HTTPException( 400, detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"}, ) field_def = def_map[name] # Validate required if field_def["required"] and (value is None or value == ""): raise HTTPException( 400, detail={"detail": f"Field '{name}' is required", "code": "required_field"}, ) # Validate select/multiselect options if field_def["field_type"] == "select" and value is not None: if value not in field_def["options"]: raise HTTPException( 400, detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"}, ) if field_def["field_type"] == "multiselect" and value is not None: if not isinstance(value, list): raise HTTPException( 400, detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"}, ) for v in value: if v not in field_def["options"]: raise HTTPException( 400, detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"}, ) current_custom[name] = value contact.custom = current_custom await db.flush() merged = await _merge_definitions_with_values(definitions, contact.custom) return {"fields": merged}