Kairo-Jinkendo/backend/entity_fields/service.py
Lars a6fc86025c
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
AP1.10b/10c: Entity Field System mit dynamischen Vorhaben-Zusatzfeldern.
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>
2026-07-09 13:53:07 +02:00

207 lines
6.7 KiB
Python

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