From a6fc86025cb8b05a01d1456e3de4bb9018b7677f Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 9 Jul 2026 13:53:07 +0200 Subject: [PATCH] AP1.10b/10c: Entity Field System mit dynamischen Vorhaben-Zusatzfeldern. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EFS-Tabellen, Registry-Sync und Fields-API ermöglichen archetyp-spezifische Felder; Frontend rendert sie im Profil-Modal per FieldRenderer. Co-authored-by: Cursor --- backend/entity_archetypes/registry.py | 40 +++- backend/entity_fields/__init__.py | 20 ++ backend/entity_fields/definitions.py | 48 ++++ backend/entity_fields/service.py | 206 ++++++++++++++++++ backend/entity_fields/sync.py | 78 +++++++ backend/entity_fields/validation.py | 153 +++++++++++++ backend/entrypoint.sh | 3 +- .../migrations/017_entity_field_system.sql | 59 +++++ backend/routers/entity_archetypes.py | 18 +- backend/routers/initiatives.py | 62 ++++++ backend/sync_entity_field_registry.py | 15 ++ backend/tests/conftest.py | 7 + backend/tests/test_ap110_entity_fields.py | 116 ++++++++++ backend/version.py | 4 +- frontend/package.json | 2 +- frontend/src/api/entityFields.js | 36 +++ frontend/src/components/FieldRenderer.jsx | 104 +++++++++ frontend/src/components/InitiativeForm.jsx | 63 +++++- .../context/InitiativeOperationsContext.jsx | 5 +- frontend/src/pages/InitiativesPage.jsx | 5 +- frontend/src/pages/WorkspacePage.jsx | 7 +- frontend/src/pages/modes/CockpitPage.jsx | 7 +- frontend/src/pages/modes/PlanProfilePage.jsx | 69 +++++- 23 files changed, 1107 insertions(+), 20 deletions(-) create mode 100644 backend/entity_fields/__init__.py create mode 100644 backend/entity_fields/definitions.py create mode 100644 backend/entity_fields/service.py create mode 100644 backend/entity_fields/sync.py create mode 100644 backend/entity_fields/validation.py create mode 100644 backend/migrations/017_entity_field_system.sql create mode 100644 backend/sync_entity_field_registry.py create mode 100644 backend/tests/test_ap110_entity_fields.py create mode 100644 frontend/src/api/entityFields.js create mode 100644 frontend/src/components/FieldRenderer.jsx diff --git a/backend/entity_archetypes/registry.py b/backend/entity_archetypes/registry.py index 63d292b..7cc802c 100644 --- a/backend/entity_archetypes/registry.py +++ b/backend/entity_archetypes/registry.py @@ -1,9 +1,13 @@ -"""System-Archetypen (AP1.10a) — Seed-Daten bis EFS-Tabellen (10b).""" +"""System-Archetypen (AP1.10a/10b) — In-Memory-Seed + DB-Registry.""" from __future__ import annotations from typing import Any, Optional +from psycopg2.extras import RealDictCursor + +from db import get_connection + INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = ( { "key": "initiative.generic", @@ -33,12 +37,44 @@ _ARCHETYPE_INDEX = { } -def list_archetypes(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]: +def _list_archetypes_from_memory(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]: if entity_type is None: return [dict(item) for item in INITIATIVE_ARCHETYPES] return [dict(item) for item in INITIATIVE_ARCHETYPES if item["entity_type"] == entity_type] +def list_archetypes(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + if entity_type is None: + cur.execute( + """ + SELECT archetype_key AS key, entity_type, label, description, is_system + FROM entity_archetypes + ORDER BY entity_type ASC, archetype_key ASC + """ + ) + else: + cur.execute( + """ + SELECT archetype_key AS key, entity_type, label, description, is_system + FROM entity_archetypes + WHERE entity_type = %s + ORDER BY archetype_key ASC + """, + (entity_type,), + ) + rows = cur.fetchall() + if rows: + return [dict(row) for row in rows] + except Exception: + pass + finally: + conn.close() + return _list_archetypes_from_memory(entity_type=entity_type) + + def validate_archetype_key(*, entity_type: str, archetype_key: str) -> None: if (entity_type, archetype_key) not in _ARCHETYPE_INDEX: raise ValueError(f"Unbekannter Archetyp: {archetype_key}") diff --git a/backend/entity_fields/__init__.py b/backend/entity_fields/__init__.py new file mode 100644 index 0000000..e54f18c --- /dev/null +++ b/backend/entity_fields/__init__.py @@ -0,0 +1,20 @@ +from entity_fields.definitions import FIELD_DEFINITIONS +from entity_fields.service import ( + get_entity_field_values, + get_initiative_archetype_key, + list_field_definitions, + patch_entity_field_values, +) +from entity_fields.sync import sync_entity_field_registry_to_db +from entity_fields.validation import normalize_field_value, validate_required_fields + +__all__ = [ + "FIELD_DEFINITIONS", + "get_entity_field_values", + "get_initiative_archetype_key", + "list_field_definitions", + "normalize_field_value", + "patch_entity_field_values", + "sync_entity_field_registry_to_db", + "validate_required_fields", +] diff --git a/backend/entity_fields/definitions.py b/backend/entity_fields/definitions.py new file mode 100644 index 0000000..613f3b0 --- /dev/null +++ b/backend/entity_fields/definitions.py @@ -0,0 +1,48 @@ +"""System-Felddefinitionen (Seed) — AP1.10b.""" + +from __future__ import annotations + +from typing import Any + +FIELD_DEFINITIONS: tuple[dict[str, Any], ...] = ( + { + "archetype_key": "initiative.program", + "field_key": "stakeholder_map", + "field_type": "longtext", + "label": "Stakeholder-Übersicht", + "required": False, + "searchable": True, + "sort_order": 10, + "validation_json": {}, + }, + { + "archetype_key": "initiative.program", + "field_key": "success_criteria", + "field_type": "longtext", + "label": "Erfolgskriterien", + "required": False, + "searchable": True, + "sort_order": 20, + "validation_json": {}, + }, + { + "archetype_key": "initiative.product", + "field_key": "release_theme", + "field_type": "text", + "label": "Release-Thema", + "required": False, + "searchable": True, + "sort_order": 10, + "validation_json": {}, + }, + { + "archetype_key": "initiative.product", + "field_key": "metrics", + "field_type": "longtext", + "label": "Metriken / KPIs", + "required": False, + "searchable": True, + "sort_order": 20, + "validation_json": {}, + }, +) diff --git a/backend/entity_fields/service.py b/backend/entity_fields/service.py new file mode 100644 index 0000000..7d808c2 --- /dev/null +++ b/backend/entity_fields/service.py @@ -0,0 +1,206 @@ +"""Entity Field System — Lesen/Schreiben dynamischer Feldwerte — AP1.10b/10c.""" + +from __future__ import annotations + +from typing import Any, Optional + +from psycopg2.extras import Json, RealDictCursor + +from db import get_connection +from entity_fields.validation import normalize_field_value, validate_required_fields + + +def _serialize_definition(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + if result.get("id"): + result["id"] = str(result["id"]) + validation = result.get("validation_json") + if validation is None: + result["validation_json"] = {} + return result + + +def _api_value_from_row(row: dict[str, Any], field_type: str) -> Any: + if field_type == "number": + if row.get("value_number") is not None: + return float(row["value_number"]) + return None + if field_type == "boolean": + if row.get("value_boolean") is not None: + return bool(row["value_boolean"]) + return False + if field_type == "date": + value_date = row.get("value_date") + if value_date is not None: + return value_date.isoformat() + return None + text = row.get("value_text") or "" + return text + + +def list_field_definitions( + *, + archetype_key: str, +) -> list[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, archetype_key, field_key, field_type, label, + required, searchable, sort_order, validation_json + FROM field_definitions + WHERE archetype_key = %s + ORDER BY sort_order ASC, field_key ASC + """, + (archetype_key,), + ) + return [_serialize_definition(dict(row)) for row in cur.fetchall()] + finally: + conn.close() + + +def get_entity_field_values( + *, + tenant_id: str, + entity_type: str, + entity_id: str, + definitions: list[dict[str, Any]], +) -> dict[str, Any]: + if not definitions: + return {} + + field_keys = [item["field_key"] for item in definitions] + type_by_key = {item["field_key"]: item["field_type"] for item in definitions} + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT field_key, value_text, value_number, value_boolean, value_date + FROM field_values + WHERE tenant_id = %s + AND entity_type = %s + AND entity_id = %s + AND field_key = ANY(%s) + """, + (tenant_id, entity_type, entity_id, field_keys), + ) + rows = {row["field_key"]: dict(row) for row in cur.fetchall()} + finally: + conn.close() + + result: dict[str, Any] = {} + for field_key in field_keys: + row = rows.get(field_key) + if row is None: + field_type = type_by_key[field_key] + if field_type == "boolean": + result[field_key] = False + else: + result[field_key] = None if field_type in {"number", "date"} else "" + else: + result[field_key] = _api_value_from_row(row, type_by_key[field_key]) + return result + + +def patch_entity_field_values( + *, + tenant_id: str, + entity_type: str, + entity_id: str, + definitions: list[dict[str, Any]], + values: dict[str, Any], +) -> dict[str, Any]: + if not values: + return get_entity_field_values( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + definitions=definitions, + ) + + allowed_keys = {item["field_key"] for item in definitions} + unknown = sorted(set(values.keys()) - allowed_keys) + if unknown: + raise ValueError(f"Unbekannte Felder: {', '.join(unknown)}") + + definition_by_key = {item["field_key"]: item for item in definitions} + merged = get_entity_field_values( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + definitions=definitions, + ) + merged.update(values) + validate_required_fields(definitions=definitions, values=merged) + + conn = get_connection() + try: + with conn.cursor() as cur: + for field_key, raw_value in values.items(): + definition = definition_by_key[field_key] + normalized = normalize_field_value( + field_type=definition["field_type"], + raw_value=raw_value, + validation_json=definition.get("validation_json"), + ) + cur.execute( + """ + INSERT INTO field_values ( + tenant_id, entity_type, entity_id, field_key, + value_json, value_text, value_number, value_boolean, value_date + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (tenant_id, entity_type, entity_id, field_key) DO UPDATE SET + value_json = EXCLUDED.value_json, + value_text = EXCLUDED.value_text, + value_number = EXCLUDED.value_number, + value_boolean = EXCLUDED.value_boolean, + value_date = EXCLUDED.value_date, + updated_at = NOW() + """, + ( + tenant_id, + entity_type, + entity_id, + field_key, + Json(normalized["value_json"]) if normalized["value_json"] is not None else None, + normalized["value_text"], + normalized["value_number"], + normalized["value_boolean"], + normalized["value_date"], + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + return get_entity_field_values( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + definitions=definitions, + ) + + +def get_initiative_archetype_key(*, tenant_id: str, initiative_id: str) -> Optional[str]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT archetype_key + FROM initiatives + WHERE tenant_id = %s AND id = %s + """, + (tenant_id, initiative_id), + ) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() diff --git a/backend/entity_fields/sync.py b/backend/entity_fields/sync.py new file mode 100644 index 0000000..7e09ff7 --- /dev/null +++ b/backend/entity_fields/sync.py @@ -0,0 +1,78 @@ +"""Sync Entity-Archetypen und Felddefinitionen in PostgreSQL — AP1.10b.""" + +from __future__ import annotations + +import json + +from db import get_connection +from entity_archetypes.registry import INITIATIVE_ARCHETYPES +from entity_fields.definitions import FIELD_DEFINITIONS + + +def sync_entity_field_registry_to_db() -> int: + conn = get_connection() + try: + with conn.cursor() as cur: + for archetype in INITIATIVE_ARCHETYPES: + cur.execute( + """ + INSERT INTO entity_archetypes ( + archetype_key, entity_type, label, description, is_system + ) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (archetype_key) DO UPDATE SET + entity_type = EXCLUDED.entity_type, + label = EXCLUDED.label, + description = EXCLUDED.description, + is_system = EXCLUDED.is_system, + updated_at = NOW() + """, + ( + archetype["key"], + archetype["entity_type"], + archetype["label"], + archetype["description"], + archetype["is_system"], + ), + ) + + for field_def in FIELD_DEFINITIONS: + validation_json = field_def.get("validation_json") or {} + cur.execute( + """ + INSERT INTO field_definitions ( + archetype_key, field_key, field_type, label, + required, searchable, sort_order, validation_json + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) + ON CONFLICT (archetype_key, field_key) DO UPDATE SET + field_type = EXCLUDED.field_type, + label = EXCLUDED.label, + required = EXCLUDED.required, + searchable = EXCLUDED.searchable, + sort_order = EXCLUDED.sort_order, + validation_json = EXCLUDED.validation_json, + updated_at = NOW() + """, + ( + field_def["archetype_key"], + field_def["field_key"], + field_def["field_type"], + field_def["label"], + field_def["required"], + field_def["searchable"], + field_def["sort_order"], + json.dumps(validation_json), + ), + ) + conn.commit() + print( + "[entity_fields] Sync OK — " + f"{len(INITIATIVE_ARCHETYPES)} Archetyp(en), " + f"{len(FIELD_DEFINITIONS)} Felddefinition(en)" + ) + return 0 + except Exception as exc: + conn.rollback() + print(f"[entity_fields] Sync FAIL: {exc}") + return 1 diff --git a/backend/entity_fields/validation.py b/backend/entity_fields/validation.py new file mode 100644 index 0000000..4bfea70 --- /dev/null +++ b/backend/entity_fields/validation.py @@ -0,0 +1,153 @@ +"""Validierung dynamischer Feldwerte gegen Definitionen — AP1.10b/10c.""" + +from __future__ import annotations + +import re +from datetime import date, datetime +from typing import Any, Optional +from urllib.parse import urlparse + +VALID_FIELD_TYPES = frozenset( + {"text", "longtext", "number", "boolean", "date", "enum", "actor_ref", "url"} +) + +_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:") + + +def _parse_date(value: str) -> date: + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"Ungültiges Datum: {value}") from exc + + +def _validate_url(value: str) -> None: + if not value.strip(): + return + parsed = urlparse(value.strip()) + if parsed.scheme and parsed.netloc: + return + if _URL_SCHEME_RE.match(value.strip()): + return + raise ValueError(f"Ungültige URL: {value}") + + +def normalize_field_value( + *, + field_type: str, + raw_value: Any, + validation_json: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Wert normalisieren und in DB-Spalten aufteilen.""" + validation_json = validation_json or {} + + if field_type in {"text", "longtext", "url", "actor_ref", "enum"}: + if raw_value is None: + text = "" + elif not isinstance(raw_value, str): + raise ValueError(f"Feld erwartet Text, erhalten: {type(raw_value).__name__}") + else: + text = raw_value.strip() + if field_type == "url" and text: + _validate_url(text) + if field_type == "enum" and text: + options = validation_json.get("options") or [] + if options and text not in options: + raise ValueError(f"Ungültiger Enum-Wert: {text}") + return { + "value_json": None, + "value_text": text, + "value_number": None, + "value_boolean": None, + "value_date": None, + "api_value": text, + } + + if field_type == "number": + if raw_value is None or raw_value == "": + return { + "value_json": None, + "value_text": "", + "value_number": None, + "value_boolean": None, + "value_date": None, + "api_value": None, + } + try: + number = float(raw_value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Ungültige Zahl: {raw_value}") from exc + return { + "value_json": None, + "value_text": str(number), + "value_number": number, + "value_boolean": None, + "value_date": None, + "api_value": number, + } + + if field_type == "boolean": + if raw_value is None: + bool_val = False + elif isinstance(raw_value, bool): + bool_val = raw_value + elif isinstance(raw_value, str): + lowered = raw_value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + bool_val = True + elif lowered in {"false", "0", "no", "off", ""}: + bool_val = False + else: + raise ValueError(f"Ungültiger Boolean-Wert: {raw_value}") + else: + bool_val = bool(raw_value) + return { + "value_json": None, + "value_text": "true" if bool_val else "false", + "value_number": None, + "value_boolean": bool_val, + "value_date": None, + "api_value": bool_val, + } + + if field_type == "date": + if raw_value is None or raw_value == "": + return { + "value_json": None, + "value_text": "", + "value_number": None, + "value_boolean": None, + "value_date": None, + "api_value": None, + } + if isinstance(raw_value, date) and not isinstance(raw_value, datetime): + parsed = raw_value + elif isinstance(raw_value, str): + parsed = _parse_date(raw_value) + else: + raise ValueError(f"Ungültiges Datum: {raw_value}") + iso = parsed.isoformat() + return { + "value_json": None, + "value_text": iso, + "value_number": None, + "value_boolean": None, + "value_date": parsed, + "api_value": iso, + } + + raise ValueError(f"Unbekannter Feldtyp: {field_type}") + + +def validate_required_fields( + *, + definitions: list[dict[str, Any]], + values: dict[str, Any], +) -> None: + for definition in definitions: + if not definition.get("required"): + continue + key = definition["field_key"] + raw = values.get(key) + if raw is None or (isinstance(raw, str) and not raw.strip()): + raise ValueError(f"Pflichtfeld fehlt: {definition.get('label') or key}") diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 8392193..6f394fc 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -23,8 +23,9 @@ fi if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then python sync_prompt_feature_config.py + python sync_entity_field_registry.py else - echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config-Sync übersprungen" + echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config/EFS-Sync übersprungen" fi export KAIRO_DB_READY=1 diff --git a/backend/migrations/017_entity_field_system.sql b/backend/migrations/017_entity_field_system.sql new file mode 100644 index 0000000..96bc685 --- /dev/null +++ b/backend/migrations/017_entity_field_system.sql @@ -0,0 +1,59 @@ +-- AP1.10b: Entity Field System (EFS) — Archetypen, Felddefinitionen, Werte + +CREATE TABLE IF NOT EXISTS entity_archetypes ( + archetype_key VARCHAR(64) PRIMARY KEY, + entity_type VARCHAR(64) NOT NULL, + label VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_system BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_entity_archetypes_entity_type + ON entity_archetypes (entity_type); + +CREATE TABLE IF NOT EXISTS field_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + archetype_key VARCHAR(64) NOT NULL + REFERENCES entity_archetypes (archetype_key) ON DELETE CASCADE, + field_key VARCHAR(64) NOT NULL, + field_type VARCHAR(32) NOT NULL + CHECK (field_type IN ( + 'text', 'longtext', 'number', 'boolean', 'date', 'enum', 'actor_ref', 'url' + )), + label VARCHAR(255) NOT NULL, + required BOOLEAN NOT NULL DEFAULT FALSE, + searchable BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0, + validation_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (archetype_key, field_key) +); + +CREATE INDEX IF NOT EXISTS idx_field_definitions_archetype + ON field_definitions (archetype_key, sort_order); + +CREATE TABLE IF NOT EXISTS field_values ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants (id) ON DELETE CASCADE, + entity_type VARCHAR(64) NOT NULL, + entity_id UUID NOT NULL, + field_key VARCHAR(64) NOT NULL, + value_json JSONB, + value_text TEXT NOT NULL DEFAULT '', + value_number NUMERIC, + value_boolean BOOLEAN, + value_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, entity_type, entity_id, field_key) +); + +CREATE INDEX IF NOT EXISTS idx_field_values_entity + ON field_values (tenant_id, entity_type, entity_id); + +CREATE INDEX IF NOT EXISTS idx_field_values_search + ON field_values (tenant_id, entity_type, field_key) + WHERE value_text <> ''; diff --git a/backend/routers/entity_archetypes.py b/backend/routers/entity_archetypes.py index 7380042..2765604 100644 --- a/backend/routers/entity_archetypes.py +++ b/backend/routers/entity_archetypes.py @@ -1,4 +1,4 @@ -"""Entity-Archetypen API — AP1.10a (In-Memory-Registry bis 10b).""" +"""Entity-Archetypen API — AP1.10a/10b.""" from __future__ import annotations @@ -6,7 +6,8 @@ from typing import Optional from capabilities import require_capability from entity_archetypes import list_archetypes -from fastapi import APIRouter, Depends, Query +from entity_fields.service import list_field_definitions +from fastapi import APIRouter, Depends, HTTPException, Query from tenant_context import TenantContext router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"]) @@ -19,3 +20,16 @@ def get_entity_archetypes( ): _ = ctx return list_archetypes(entity_type=entity_type) + + +@router.get("/field-definitions") +def get_archetype_field_definitions( + entity_type: str = Query(..., min_length=1), + archetype_key: str = Query(..., min_length=1), + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + _ = ctx + archetypes = list_archetypes(entity_type=entity_type) + if not any(item["key"] == archetype_key for item in archetypes): + raise HTTPException(status_code=404, detail="Archetyp nicht gefunden") + return list_field_definitions(archetype_key=archetype_key) diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 75c509e..46ab5b4 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -7,6 +7,11 @@ from typing import Literal, Optional from capabilities import require_capability from data_layer import initiative_snapshot as dl_initiative_snapshot +from entity_fields.service import ( + get_entity_field_values, + list_field_definitions, + patch_entity_field_values, +) from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from steering.context import get_steering_context_dto @@ -46,6 +51,10 @@ class InitiativeUpdateRequest(BaseModel): owner_actor_id: Optional[str] = None +class InitiativeFieldsPatchRequest(BaseModel): + fields: dict[str, object] = Field(default_factory=dict) + + class ActionCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" @@ -194,6 +203,59 @@ def get_initiative_steering_context( return get_steering_context_dto(ctx, initiative_id=initiative_id) +def _require_initiative(*, tenant_id: str, initiative_id: str) -> dict: + initiative = initiative_service.get_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + if not initiative: + raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") + return initiative + + +@router.get("/{initiative_id}/field-definitions") +def get_initiative_field_definitions( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id) + return list_field_definitions(archetype_key=initiative["archetype_key"]) + + +@router.get("/{initiative_id}/fields") +def get_initiative_fields( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id) + definitions = list_field_definitions(archetype_key=initiative["archetype_key"]) + return get_entity_field_values( + tenant_id=ctx.tenant_id, + entity_type="initiative", + entity_id=initiative_id, + definitions=definitions, + ) + + +@router.patch("/{initiative_id}/fields") +def patch_initiative_fields( + initiative_id: str, + body: InitiativeFieldsPatchRequest, + ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")), +): + initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id) + definitions = list_field_definitions(archetype_key=initiative["archetype_key"]) + try: + return patch_entity_field_values( + tenant_id=ctx.tenant_id, + entity_type="initiative", + entity_id=initiative_id, + definitions=definitions, + values=body.fields, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.patch("/{initiative_id}") def update_initiative( initiative_id: str, diff --git a/backend/sync_entity_field_registry.py b/backend/sync_entity_field_registry.py new file mode 100644 index 0000000..06fa317 --- /dev/null +++ b/backend/sync_entity_field_registry.py @@ -0,0 +1,15 @@ +"""Startup sync for Entity Field System registry — AP1.10b.""" + +from __future__ import annotations + +import sys + +from entity_fields.sync import sync_entity_field_registry_to_db + + +def main() -> int: + return sync_entity_field_registry_to_db() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 71b3da4..6a0fc18 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -85,6 +85,13 @@ def _sync_prompt_feature_config(): assert sync_registries() == 0 +@pytest.fixture(scope="session", autouse=True) +def _sync_entity_field_registry(): + from sync_entity_field_registry import main as sync_efs + + assert sync_efs() == 0 + + @pytest.fixture() def client(): import importlib diff --git a/backend/tests/test_ap110_entity_fields.py b/backend/tests/test_ap110_entity_fields.py new file mode 100644 index 0000000..b953315 --- /dev/null +++ b/backend/tests/test_ap110_entity_fields.py @@ -0,0 +1,116 @@ +"""Entity Field System (AP1.10b/10c).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_entity_field_registry_sync(): + from sync_entity_field_registry import main + + assert main() == 0 + + +def test_program_field_definitions(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + res = client.get( + "/api/entity-archetypes/field-definitions" + "?entity_type=initiative&archetype_key=initiative.program", + headers=_auth(token), + ) + assert res.status_code == 200 + keys = {item["field_key"] for item in res.json()} + assert keys == {"stakeholder_map", "success_criteria"} + + +def test_initiative_dynamic_fields_roundtrip(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Programm Nord", + archetype_key="initiative.program", + ) + assert created.status_code == 201 + initiative_id = created.json()["id"] + + defs_res = client.get( + f"/api/initiatives/{initiative_id}/field-definitions", + headers=_auth(token), + ) + assert defs_res.status_code == 200 + assert len(defs_res.json()) == 2 + + empty = client.get( + f"/api/initiatives/{initiative_id}/fields", + headers=_auth(token), + ) + assert empty.status_code == 200 + assert empty.json()["stakeholder_map"] == "" + + patched = client.patch( + f"/api/initiatives/{initiative_id}/fields", + json={ + "fields": { + "stakeholder_map": "PO: Anna\nTech: Bob", + "success_criteria": "Go-Live Q3", + } + }, + headers=_auth(token), + ) + assert patched.status_code == 200 + body = patched.json() + assert body["stakeholder_map"] == "PO: Anna\nTech: Bob" + assert body["success_criteria"] == "Go-Live Q3" + + +def test_unknown_dynamic_field_rejected(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Produkt Beta", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + res = client.patch( + f"/api/initiatives/{initiative_id}/fields", + json={"fields": {"unknown_field": "x"}}, + headers=_auth(token), + ) + assert res.status_code == 400 + + +def test_generic_initiative_has_no_dynamic_fields(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Generic", + archetype_key="initiative.generic", + ) + initiative_id = created.json()["id"] + + defs_res = client.get( + f"/api/initiatives/{initiative_id}/field-definitions", + headers=_auth(token), + ) + assert defs_res.status_code == 200 + assert defs_res.json() == [] + + fields_res = client.get( + f"/api/initiatives/{initiative_id}/fields", + headers=_auth(token), + ) + assert fields_res.status_code == 200 + assert fields_res.json() == {} diff --git a/backend/version.py b/backend/version.py index 6fcc6e7..efad8a3 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.17.0-ap1.10a" -DB_SCHEMA_VERSION = "016" +APP_VERSION = "0.17.0-ap1.10c" +DB_SCHEMA_VERSION = "017" APP_NAME = "jinkendo-kairo" diff --git a/frontend/package.json b/frontend/package.json index fb6eaac..567f095 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.13.0-ap1.4", + "version": "0.17.0-ap1.10c", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/entityFields.js b/frontend/src/api/entityFields.js new file mode 100644 index 0000000..f3f8893 --- /dev/null +++ b/frontend/src/api/entityFields.js @@ -0,0 +1,36 @@ +import { apiFetch } from './client.js' + +export function listArchetypeFieldDefinitions(entityType, archetypeKey) { + const params = new URLSearchParams({ + entity_type: entityType, + archetype_key: archetypeKey, + }) + return apiFetch(`/api/entity-archetypes/field-definitions?${params}`) +} + +export function getInitiativeFieldDefinitions(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/field-definitions`) +} + +export function getInitiativeFields(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/fields`) +} + +export function patchInitiativeFields(initiativeId, fields) { + return apiFetch(`/api/initiatives/${initiativeId}/fields`, { + method: 'PATCH', + body: JSON.stringify({ fields }), + }) +} + +export async function saveInitiativeDynamicFields(initiativeId, dynamicFields) { + if (!dynamicFields || Object.keys(dynamicFields).length === 0) { + return + } + await patchInitiativeFields(initiativeId, dynamicFields) +} + +export function splitInitiativeFormPayload(payload) { + const { dynamicFields, ...core } = payload + return { core, dynamicFields: dynamicFields || {} } +} diff --git a/frontend/src/components/FieldRenderer.jsx b/frontend/src/components/FieldRenderer.jsx new file mode 100644 index 0000000..29b3db8 --- /dev/null +++ b/frontend/src/components/FieldRenderer.jsx @@ -0,0 +1,104 @@ +export function FieldRenderer({ definitions, values, onChange, disabled = false }) { + if (!definitions?.length) { + return null + } + + return ( +
+ Zusatzfelder + {definitions.map((def) => ( + onChange(def.field_key, next)} + disabled={disabled} + /> + ))} +
+ ) +} + +function DynamicField({ definition, value, onChange, disabled }) { + const { field_key, field_type, label, required, validation_json: validation } = definition + const inputId = `field-${field_key}` + + if (field_type === 'longtext') { + return ( +