From c6decf55569e18831ddb15e0d78d086e68a8b675 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 28 Aug 2026 13:20:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(#357):=20W4c=20=E2=80=94=20Custom-Fields-R?= =?UTF-8?q?outen=20aus=20Core=20in=20ContactsPlugin=20migriert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/main.py | 2 - app/plugins/builtins/contacts/routes.py | 192 +++++++++++++++++++++- app/routes/custom_fields.py | 201 ------------------------ 3 files changed, 191 insertions(+), 204 deletions(-) delete mode 100644 app/routes/custom_fields.py diff --git a/app/main.py b/app/main.py index b255c7e..42a4eb0 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/plugins/builtins/contacts/routes.py b/app/plugins/builtins/contacts/routes.py index 149dd7a..8f92948 100644 --- a/app/plugins/builtins/contacts/routes.py +++ b/app/plugins/builtins/contacts/routes.py @@ -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} diff --git a/app/routes/custom_fields.py b/app/routes/custom_fields.py deleted file mode 100644 index 5c5f909..0000000 --- a/app/routes/custom_fields.py +++ /dev/null @@ -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}