Files
leocrm/app/routes/custom_fields.py
T
Agent Zero a7e3890634 Phase 2: Tags UI, Custom Fields UI, Notifications Bell
- Tags UI: TagsPage (CRUD, color picker), TagBadge, TagSelector (multi-select, inline creation)
- Custom Fields Backend: model, schema, service, routes, migration 0041
- Custom Fields Frontend: CustomFieldsPage (definitions CRUD), CustomFieldRenderer (dynamic field rendering)
- Custom Fields: _collect_custom_field_definitions() extended to merge DB definitions with plugin definitions
- Notifications Bell: NotificationBell (30s polling, unread badge), NotificationDropdown, NotificationItem
- NotificationBell integrated into TopBar
- Routes: /tags, /settings/custom-fields registered
- Settings nav: Custom Fields entry added
- Menu items: Tags added to automation plugin manifest
2026-07-26 03:02:25 +02:00

199 lines
7.3 KiB
Python

"""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}