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>
207 lines
6.7 KiB
Python
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()
|