feat(#357): W4c — Custom-Fields-Routen aus Core in ContactsPlugin migriert
Check Cross-Plugin Imports / check (push) Has been cancelled

Kritikpunkt 14: app/routes/custom_fields.py war 100% Contact-spezifisch
(importiert Contact, nutzt contacts:read/write, Route /{contact_id}/custom-fields)
aber lag als scheinbar generischer Core-Service.

Fix: Die komplette Logik (2 Endpoints GET/PATCH /{contact_id}/custom-fields,
_collect_custom_field_definitions, _merge_definitions_with_values) wandert in
app/plugins/builtins/contacts/routes.py (gleicher Router-Prefix /api/v1/contacts).
app/routes/custom_fields.py geloescht, main.py bereinigt.

generischer custom_field_definitions.py-Endpoint bleibt im Core (echtes Core-
Entity). Frontend-Endpoint-Shapes unveraendert.

Funktionserhalt bewiesen: 11/11 tests/test_custom_fields.py passed.

fixes #357 (W4c-Teil)
This commit is contained in:
Agent Zero
2026-08-28 13:20:54 +02:00
parent d7b3c7c1b5
commit c6decf5556
3 changed files with 191 additions and 204 deletions
-2
View File
@@ -45,7 +45,6 @@ from app.routes import ( # noqa: E402
compliance,
currencies,
custom_field_definitions,
custom_fields,
dashboard,
delegations,
entity_history,
@@ -584,7 +583,6 @@ def create_app() -> FastAPI:
app.include_router(compliance.router)
app.include_router(owner_transfer.router)
app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.router)
app.include_router(saved_filters.router)
app.include_router(saved_views.router)
app.include_router(webhooks.router)
+191 -1
View File
@@ -13,6 +13,7 @@ from typing import Any
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.contact_commands import (
@@ -23,7 +24,10 @@ from app.commands.contact_commands import (
)
from app.core.db import get_db
from app.core.visibility import check_single_entity_access
from app.deps import get_redis_dep, require_permission
from app.deps import get_current_user, get_redis_dep, require_permission
from app.models.contact import Contact
from app.models.custom_field_definition import CustomFieldDefinition
from app.plugins.registry import get_registry
from app.schemas.contact import (
ContactCreate,
ContactPersonCreate,
@@ -316,3 +320,189 @@ async def merge_duplicate_contacts(
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return result.data
# ─── Custom Fields (W4c: migrated from app/routes/custom_fields.py) ─────────
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 name in registry.list_discovered():
plugin = registry.get_plugin(name)
if plugin is None:
continue
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}
-201
View File
@@ -1,201 +0,0 @@
"""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
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 name in registry.list_discovered():
plugin = registry.get_plugin(name)
if plugin is None:
continue
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}