AP1.10b/10c: Entity Field System mit dynamischen Vorhaben-Zusatzfeldern.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 15s

EFS-Tabellen, Registry-Sync und Fields-API ermöglichen archetyp-spezifische Felder; Frontend rendert sie im Profil-Modal per FieldRenderer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-09 13:53:07 +02:00
parent 408050bcb8
commit a6fc86025c
23 changed files with 1107 additions and 20 deletions

View File

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

View File

@ -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",
]

View File

@ -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": {},
},
)

View File

@ -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()

View File

@ -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

View File

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

View File

@ -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

View File

@ -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 <> '';

View File

@ -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)

View File

@ -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,

View File

@ -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())

View File

@ -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

View File

@ -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() == {}

View File

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

View File

@ -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": {

View File

@ -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 || {} }
}

View File

@ -0,0 +1,104 @@
export function FieldRenderer({ definitions, values, onChange, disabled = false }) {
if (!definitions?.length) {
return null
}
return (
<fieldset className="field-renderer">
<legend className="field-renderer__legend">Zusatzfelder</legend>
{definitions.map((def) => (
<DynamicField
key={def.field_key}
definition={def}
value={values?.[def.field_key]}
onChange={(next) => onChange(def.field_key, next)}
disabled={disabled}
/>
))}
</fieldset>
)
}
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 (
<label htmlFor={inputId}>
{label}
{required && ' *'}
<textarea
id={inputId}
name={field_key}
rows={4}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
required={required}
disabled={disabled}
/>
</label>
)
}
if (field_type === 'boolean') {
return (
<label className="checkbox-label" htmlFor={inputId}>
<input
id={inputId}
name={field_key}
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
{label}
{required && ' *'}
</label>
)
}
if (field_type === 'enum') {
const options = validation?.options || []
return (
<label htmlFor={inputId}>
{label}
{required && ' *'}
<select
id={inputId}
name={field_key}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
required={required}
disabled={disabled}
>
<option value=""></option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
)
}
const inputType =
field_type === 'number' ? 'number' : field_type === 'date' ? 'date' : field_type === 'url' ? 'url' : 'text'
return (
<label htmlFor={inputId}>
{label}
{required && ' *'}
<input
id={inputId}
name={field_key}
type={inputType}
value={value ?? ''}
onChange={(e) => onChange(field_type === 'number' ? e.target.value : e.target.value)}
required={required}
disabled={disabled}
/>
</label>
)
}

View File

@ -3,15 +3,26 @@ import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
import { listEntityArchetypes } from '../api/entityArchetypes.js'
import {
getInitiativeFieldDefinitions,
getInitiativeFields,
listArchetypeFieldDefinitions,
} from '../api/entityFields.js'
import { FieldRenderer } from './FieldRenderer.jsx'
export function InitiativeForm({
initial = {},
initiativeId = null,
onSubmit,
onCancel,
busy = false,
submitLabel = 'Speichern',
}) {
const [archetypes, setArchetypes] = useState(null)
const [archetypeKey, setArchetypeKey] = useState(initial.archetype_key || 'initiative.generic')
const [fieldDefinitions, setFieldDefinitions] = useState([])
const [dynamicValues, setDynamicValues] = useState({})
const [fieldsLoading, setFieldsLoading] = useState(false)
useEffect(() => {
let cancelled = false
@ -34,6 +45,40 @@ export function InitiativeForm({
}
}, [])
useEffect(() => {
let cancelled = false
setFieldsLoading(true)
async function loadFields() {
try {
let definitions = []
let values = {}
if (initiativeId) {
definitions = await getInitiativeFieldDefinitions(initiativeId)
values = await getInitiativeFields(initiativeId)
} else if (archetypeKey) {
definitions = await listArchetypeFieldDefinitions('initiative', archetypeKey)
}
if (!cancelled) {
setFieldDefinitions(definitions)
setDynamicValues(values)
}
} catch {
if (!cancelled) {
setFieldDefinitions([])
setDynamicValues({})
}
} finally {
if (!cancelled) setFieldsLoading(false)
}
}
loadFields()
return () => {
cancelled = true
}
}, [initiativeId, archetypeKey])
async function handleSubmit(e) {
e.preventDefault()
const form = e.target
@ -45,9 +90,16 @@ export function InitiativeForm({
target_state_summary: form.target_state_summary.value,
status: form.status.value,
priority: form.priority.value,
dynamicFields: dynamicValues,
})
}
function handleDynamicChange(fieldKey, value) {
setDynamicValues((prev) => ({ ...prev, [fieldKey]: value }))
}
const formReady = archetypes && !fieldsLoading
return (
<form className="form workspace-form" onSubmit={handleSubmit}>
<label>
@ -58,7 +110,8 @@ export function InitiativeForm({
Archetyp
<select
name="archetype_key"
defaultValue={initial.archetype_key || 'initiative.generic'}
value={archetypeKey}
onChange={(e) => setArchetypeKey(e.target.value)}
disabled={!archetypes}
>
{(archetypes || []).map((item) => (
@ -84,6 +137,12 @@ export function InitiativeForm({
defaultValue={initial.target_state_summary || ''}
/>
</label>
<FieldRenderer
definitions={fieldDefinitions}
values={dynamicValues}
onChange={handleDynamicChange}
disabled={busy || fieldsLoading}
/>
<div className="form-row form-row--2">
<label>
Status
@ -107,7 +166,7 @@ export function InitiativeForm({
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy || !archetypes}>
<button type="submit" className="btn btn-primary" disabled={busy || !formReady}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (

View File

@ -15,6 +15,7 @@ import {
updateInitiative,
} from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
import {
listInitiativeBlockers,
createInitiativeBlocker,
@ -312,8 +313,10 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleUpdateInitiative(body) {
setFormBusy(true)
const { core, dynamicFields } = splitInitiativeFormPayload(body)
try {
await updateInitiative(id, body)
await updateInitiative(id, core)
await saveInitiativeDynamicFields(id, dynamicFields)
await load()
return true
} catch (err) {

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { listInitiatives, listInitiativeActions, createInitiative } from '../api/initiatives.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
import { countOpenActions } from '../api/actions.js'
import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
@ -55,8 +56,10 @@ export function InitiativesPage() {
async function handleCreate(payload) {
setFormBusy(true)
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
try {
await createInitiative({ ...payload, owner_actor_id: context?.actor?.id })
const created = await createInitiative({ ...core, owner_actor_id: context?.actor?.id })
await saveInitiativeDynamicFields(created.id, dynamicFields)
setShowForm(false)
await load()
} catch (err) {

View File

@ -5,6 +5,7 @@ import { getWidgetsForArea } from '../registry/widgetRegistry.js'
import { useSession } from '../context/SessionContext.jsx'
import { InitiativeForm } from '../components/InitiativeForm.jsx'
import { createInitiative } from '../api/initiatives.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
import { Link } from 'react-router-dom'
export function WorkspacePage() {
@ -19,11 +20,13 @@ export function WorkspacePage() {
async function handleCreateInitiative(payload) {
setFormBusy(true)
setFormError(null)
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
try {
await createInitiative({
...payload,
const created = await createInitiative({
...core,
owner_actor_id: context?.actor?.id,
})
await saveInitiativeDynamicFields(created.id, dynamicFields)
setShowForm(false)
navigate('/initiatives')
} catch (err) {

View File

@ -6,6 +6,7 @@ import { getWidgetsForArea } from '../../registry/widgetRegistry.js'
import { useSession } from '../../context/SessionContext.jsx'
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { createInitiative } from '../../api/initiatives.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
import { ModeShell } from '../../components/ModeShell.jsx'
import { initiativeCatalogPath } from '../../utils/routes.js'
@ -21,11 +22,13 @@ export function CockpitPage() {
async function handleCreateInitiative(payload) {
setFormBusy(true)
setFormError(null)
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
try {
await createInitiative({
...payload,
const created = await createInitiative({
...core,
owner_actor_id: context?.actor?.id,
})
await saveInitiativeDynamicFields(created.id, dynamicFields)
setShowForm(false)
navigate(initiativeCatalogPath())
} catch (err) {

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
@ -8,6 +8,68 @@ import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { initiativeArchetypeLabel } from '../../constants/initiativeArchetypes.js'
import {
getInitiativeFieldDefinitions,
getInitiativeFields,
} from '../../api/entityFields.js'
function DynamicFieldsDisplay({ initiativeId }) {
const [definitions, setDefinitions] = useState([])
const [values, setValues] = useState(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [defs, fieldValues] = await Promise.all([
getInitiativeFieldDefinitions(initiativeId),
getInitiativeFields(initiativeId),
])
if (!cancelled) {
setDefinitions(defs)
setValues(fieldValues)
}
} catch {
if (!cancelled) {
setDefinitions([])
setValues({})
}
}
}
load()
return () => {
cancelled = true
}
}, [initiativeId])
if (!definitions.length || values === null) {
return null
}
const visible = definitions.filter((def) => {
const val = values[def.field_key]
if (def.field_type === 'boolean') return val === true
return val != null && String(val).trim() !== ''
})
if (!visible.length) {
return null
}
return visible.map((def) => (
<div key={def.field_key} className="plan-profile__field">
<h3 className="plan-profile__label">{def.label}</h3>
<p>{formatFieldValue(def, values[def.field_key])}</p>
</div>
))
}
function formatFieldValue(definition, value) {
if (definition.field_type === 'boolean') {
return value ? 'Ja' : 'Nein'
}
return String(value ?? '')
}
function PlanProfileInner() {
const ops = useInitiativeOperations()
@ -77,13 +139,12 @@ function PlanProfileInner() {
<p>{initiative.target_state_summary}</p>
</div>
)}
<p className="muted plan-profile__note">
Dynamische Zusatzfelder (EFS) folgen in AP1.10c.
</p>
<DynamicFieldsDisplay initiativeId={initiative.id} key={editing ? 'closed' : 'open'} />
</section>
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)} size="lg">
<InitiativeForm
initiativeId={initiative.id}
initial={initiative}
onSubmit={handleSave}
onCancel={() => setEditing(false)}