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
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:
parent
408050bcb8
commit
a6fc86025c
|
|
@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
|
||||||
INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = (
|
INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = (
|
||||||
{
|
{
|
||||||
"key": "initiative.generic",
|
"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:
|
if entity_type is None:
|
||||||
return [dict(item) for item in INITIATIVE_ARCHETYPES]
|
return [dict(item) for item in INITIATIVE_ARCHETYPES]
|
||||||
return [dict(item) for item in INITIATIVE_ARCHETYPES if item["entity_type"] == entity_type]
|
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:
|
def validate_archetype_key(*, entity_type: str, archetype_key: str) -> None:
|
||||||
if (entity_type, archetype_key) not in _ARCHETYPE_INDEX:
|
if (entity_type, archetype_key) not in _ARCHETYPE_INDEX:
|
||||||
raise ValueError(f"Unbekannter Archetyp: {archetype_key}")
|
raise ValueError(f"Unbekannter Archetyp: {archetype_key}")
|
||||||
|
|
|
||||||
20
backend/entity_fields/__init__.py
Normal file
20
backend/entity_fields/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
48
backend/entity_fields/definitions.py
Normal file
48
backend/entity_fields/definitions.py
Normal 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": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
206
backend/entity_fields/service.py
Normal file
206
backend/entity_fields/service.py
Normal 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()
|
||||||
78
backend/entity_fields/sync.py
Normal file
78
backend/entity_fields/sync.py
Normal 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
|
||||||
153
backend/entity_fields/validation.py
Normal file
153
backend/entity_fields/validation.py
Normal 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}")
|
||||||
|
|
@ -23,8 +23,9 @@ fi
|
||||||
|
|
||||||
if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then
|
if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then
|
||||||
python sync_prompt_feature_config.py
|
python sync_prompt_feature_config.py
|
||||||
|
python sync_entity_field_registry.py
|
||||||
else
|
else
|
||||||
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config-Sync übersprungen"
|
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config/EFS-Sync übersprungen"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export KAIRO_DB_READY=1
|
export KAIRO_DB_READY=1
|
||||||
|
|
|
||||||
59
backend/migrations/017_entity_field_system.sql
Normal file
59
backend/migrations/017_entity_field_system.sql
Normal 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 <> '';
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Entity-Archetypen API — AP1.10a (In-Memory-Registry bis 10b)."""
|
"""Entity-Archetypen API — AP1.10a/10b."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -6,7 +6,8 @@ from typing import Optional
|
||||||
|
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
from entity_archetypes import list_archetypes
|
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
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"])
|
router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"])
|
||||||
|
|
@ -19,3 +20,16 @@ def get_entity_archetypes(
|
||||||
):
|
):
|
||||||
_ = ctx
|
_ = ctx
|
||||||
return list_archetypes(entity_type=entity_type)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ from typing import Literal, Optional
|
||||||
|
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
from data_layer import initiative_snapshot as dl_initiative_snapshot
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from steering.context import get_steering_context_dto
|
from steering.context import get_steering_context_dto
|
||||||
|
|
@ -46,6 +51,10 @@ class InitiativeUpdateRequest(BaseModel):
|
||||||
owner_actor_id: Optional[str] = None
|
owner_actor_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InitiativeFieldsPatchRequest(BaseModel):
|
||||||
|
fields: dict[str, object] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class ActionCreateRequest(BaseModel):
|
class ActionCreateRequest(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
@ -194,6 +203,59 @@ def get_initiative_steering_context(
|
||||||
return get_steering_context_dto(ctx, initiative_id=initiative_id)
|
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}")
|
@router.patch("/{initiative_id}")
|
||||||
def update_initiative(
|
def update_initiative(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
|
||||||
15
backend/sync_entity_field_registry.py
Normal file
15
backend/sync_entity_field_registry.py
Normal 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())
|
||||||
|
|
@ -85,6 +85,13 @@ def _sync_prompt_feature_config():
|
||||||
assert sync_registries() == 0
|
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()
|
@pytest.fixture()
|
||||||
def client():
|
def client():
|
||||||
import importlib
|
import importlib
|
||||||
|
|
|
||||||
116
backend/tests/test_ap110_entity_fields.py
Normal file
116
backend/tests/test_ap110_entity_fields.py
Normal 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() == {}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.17.0-ap1.10a"
|
APP_VERSION = "0.17.0-ap1.10c"
|
||||||
DB_SCHEMA_VERSION = "016"
|
DB_SCHEMA_VERSION = "017"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.13.0-ap1.4",
|
"version": "0.17.0-ap1.10c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
36
frontend/src/api/entityFields.js
Normal file
36
frontend/src/api/entityFields.js
Normal 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 || {} }
|
||||||
|
}
|
||||||
104
frontend/src/components/FieldRenderer.jsx
Normal file
104
frontend/src/components/FieldRenderer.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,15 +3,26 @@ import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
|
||||||
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
||||||
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
|
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
|
||||||
import { listEntityArchetypes } from '../api/entityArchetypes.js'
|
import { listEntityArchetypes } from '../api/entityArchetypes.js'
|
||||||
|
import {
|
||||||
|
getInitiativeFieldDefinitions,
|
||||||
|
getInitiativeFields,
|
||||||
|
listArchetypeFieldDefinitions,
|
||||||
|
} from '../api/entityFields.js'
|
||||||
|
import { FieldRenderer } from './FieldRenderer.jsx'
|
||||||
|
|
||||||
export function InitiativeForm({
|
export function InitiativeForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
|
initiativeId = null,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy = false,
|
busy = false,
|
||||||
submitLabel = 'Speichern',
|
submitLabel = 'Speichern',
|
||||||
}) {
|
}) {
|
||||||
const [archetypes, setArchetypes] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
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) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.target
|
const form = e.target
|
||||||
|
|
@ -45,9 +90,16 @@ export function InitiativeForm({
|
||||||
target_state_summary: form.target_state_summary.value,
|
target_state_summary: form.target_state_summary.value,
|
||||||
status: form.status.value,
|
status: form.status.value,
|
||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
|
dynamicFields: dynamicValues,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDynamicChange(fieldKey, value) {
|
||||||
|
setDynamicValues((prev) => ({ ...prev, [fieldKey]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formReady = archetypes && !fieldsLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form workspace-form" onSubmit={handleSubmit}>
|
<form className="form workspace-form" onSubmit={handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -58,7 +110,8 @@ export function InitiativeForm({
|
||||||
Archetyp
|
Archetyp
|
||||||
<select
|
<select
|
||||||
name="archetype_key"
|
name="archetype_key"
|
||||||
defaultValue={initial.archetype_key || 'initiative.generic'}
|
value={archetypeKey}
|
||||||
|
onChange={(e) => setArchetypeKey(e.target.value)}
|
||||||
disabled={!archetypes}
|
disabled={!archetypes}
|
||||||
>
|
>
|
||||||
{(archetypes || []).map((item) => (
|
{(archetypes || []).map((item) => (
|
||||||
|
|
@ -84,6 +137,12 @@ export function InitiativeForm({
|
||||||
defaultValue={initial.target_state_summary || ''}
|
defaultValue={initial.target_state_summary || ''}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<FieldRenderer
|
||||||
|
definitions={fieldDefinitions}
|
||||||
|
values={dynamicValues}
|
||||||
|
onChange={handleDynamicChange}
|
||||||
|
disabled={busy || fieldsLoading}
|
||||||
|
/>
|
||||||
<div className="form-row form-row--2">
|
<div className="form-row form-row--2">
|
||||||
<label>
|
<label>
|
||||||
Status
|
Status
|
||||||
|
|
@ -107,7 +166,7 @@ export function InitiativeForm({
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-actions">
|
<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}
|
{busy ? 'Speichern …' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
updateInitiative,
|
updateInitiative,
|
||||||
} from '../api/initiatives.js'
|
} from '../api/initiatives.js'
|
||||||
import { updateAction, setActionAssignments } from '../api/actions.js'
|
import { updateAction, setActionAssignments } from '../api/actions.js'
|
||||||
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||||
import {
|
import {
|
||||||
listInitiativeBlockers,
|
listInitiativeBlockers,
|
||||||
createInitiativeBlocker,
|
createInitiativeBlocker,
|
||||||
|
|
@ -312,8 +313,10 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
|
|
||||||
async function handleUpdateInitiative(body) {
|
async function handleUpdateInitiative(body) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
|
const { core, dynamicFields } = splitInitiativeFormPayload(body)
|
||||||
try {
|
try {
|
||||||
await updateInitiative(id, body)
|
await updateInitiative(id, core)
|
||||||
|
await saveInitiativeDynamicFields(id, dynamicFields)
|
||||||
await load()
|
await load()
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listInitiatives, listInitiativeActions, createInitiative } from '../api/initiatives.js'
|
import { listInitiatives, listInitiativeActions, createInitiative } from '../api/initiatives.js'
|
||||||
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||||
import { countOpenActions } from '../api/actions.js'
|
import { countOpenActions } from '../api/actions.js'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
|
|
@ -55,8 +56,10 @@ export function InitiativesPage() {
|
||||||
|
|
||||||
async function handleCreate(payload) {
|
async function handleCreate(payload) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
|
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
|
||||||
try {
|
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)
|
setShowForm(false)
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getWidgetsForArea } from '../registry/widgetRegistry.js'
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
||||||
import { createInitiative } from '../api/initiatives.js'
|
import { createInitiative } from '../api/initiatives.js'
|
||||||
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export function WorkspacePage() {
|
export function WorkspacePage() {
|
||||||
|
|
@ -19,11 +20,13 @@ export function WorkspacePage() {
|
||||||
async function handleCreateInitiative(payload) {
|
async function handleCreateInitiative(payload) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
|
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
|
||||||
try {
|
try {
|
||||||
await createInitiative({
|
const created = await createInitiative({
|
||||||
...payload,
|
...core,
|
||||||
owner_actor_id: context?.actor?.id,
|
owner_actor_id: context?.actor?.id,
|
||||||
})
|
})
|
||||||
|
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
navigate('/initiatives')
|
navigate('/initiatives')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { getWidgetsForArea } from '../../registry/widgetRegistry.js'
|
||||||
import { useSession } from '../../context/SessionContext.jsx'
|
import { useSession } from '../../context/SessionContext.jsx'
|
||||||
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||||
import { createInitiative } from '../../api/initiatives.js'
|
import { createInitiative } from '../../api/initiatives.js'
|
||||||
|
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
|
||||||
import { ModeShell } from '../../components/ModeShell.jsx'
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
import { initiativeCatalogPath } from '../../utils/routes.js'
|
import { initiativeCatalogPath } from '../../utils/routes.js'
|
||||||
|
|
||||||
|
|
@ -21,11 +22,13 @@ export function CockpitPage() {
|
||||||
async function handleCreateInitiative(payload) {
|
async function handleCreateInitiative(payload) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
|
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
|
||||||
try {
|
try {
|
||||||
await createInitiative({
|
const created = await createInitiative({
|
||||||
...payload,
|
...core,
|
||||||
owner_actor_id: context?.actor?.id,
|
owner_actor_id: context?.actor?.id,
|
||||||
})
|
})
|
||||||
|
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
navigate(initiativeCatalogPath())
|
navigate(initiativeCatalogPath())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
||||||
|
|
@ -8,6 +8,68 @@ import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||||
import { initiativeArchetypeLabel } from '../../constants/initiativeArchetypes.js'
|
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() {
|
function PlanProfileInner() {
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
|
|
@ -77,13 +139,12 @@ function PlanProfileInner() {
|
||||||
<p>{initiative.target_state_summary}</p>
|
<p>{initiative.target_state_summary}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="muted plan-profile__note">
|
<DynamicFieldsDisplay initiativeId={initiative.id} key={editing ? 'closed' : 'open'} />
|
||||||
Dynamische Zusatzfelder (EFS) folgen in AP1.10c.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)} size="lg">
|
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)} size="lg">
|
||||||
<InitiativeForm
|
<InitiativeForm
|
||||||
|
initiativeId={initiative.id}
|
||||||
initial={initiative}
|
initial={initiative}
|
||||||
onSubmit={handleSave}
|
onSubmit={handleSave}
|
||||||
onCancel={() => setEditing(false)}
|
onCancel={() => setEditing(false)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user