diff --git a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md index 2547d2e..4ffb079 100644 --- a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md +++ b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md @@ -41,7 +41,7 @@ KI / UI / Export ### 2.2 `activity_log` (Spine + heiße Skalare) -**Maschinenlesbarer Kanon:** `backend/data_layer/activity_data_canon.py` — `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` wird aus `csv_parser.module_registry` (`activity.fields`) abgeleitet; zusätzlich `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` und Legacy-Lesefallback für EAV-primäre Parameter. +**Maschinenlesbarer Kanon:** `backend/data_layer/activity_data_canon.py` (`ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`, `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS`, Legacy-Lesefallback für EAV-primäre Parameter). **Immer (fachlich minimal + listenfähig):** `id`, `profile_id`, Kalender-/Zeitfenster (`date`, `started_at`/`ended_at`, ggf. `start_time`/`end_time` bis Konsolidierung), `duration_min`, `training_type_id` (+ ggf. denormalisierte Kategorie), Legacy `activity_type`, `notes`, `source`, `created`. @@ -101,7 +101,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel **Inhalt:** Schriftliche **Kanon-Tabelle**: pro Messgröße genau eine Quelle (`activity_log` | `eav_scalar` | `eav_composite` | `session_quality`). Liste der Keys, für die **Sync/Spiegelung** endet. -**Definition of Done:** Review im Team; Referenz in diesem Dokument oder Verweis auf Gitea-Kommentar. **Code (2026-04-16):** Spine-Keys des CSV-Moduls `activity` sind nur noch die Registry-Keys (`get_activity_module_registry_field_keys`); CSV-Minimal-Insert + `update_activity_columns` + DB-Read im Import-Eval-Hook — keine duplizierte hr_avg-Verdrahtung im Executor. +**Definition of Done:** Review im Team; Referenz in diesem Dokument oder Verweis auf Gitea-Kommentar; keine Code-Änderung zwingend. **Erster konkreter Schritt:** Kanon-Tabelle als Checkliste (Spreadsheet oder Gitea-Issue) – **eine Zeile pro Semantik**. diff --git a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md index a12d5a9..604834f 100644 --- a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +++ b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md @@ -6,18 +6,10 @@ **Zielarchitektur, Phasenplan (Produktionsreife):** [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md) – Kanon `activity_log`/EAV, Composites, Import, Layer 1/2, Reihenfolge A–F. -**Kanon (Code):** `backend/data_layer/activity_data_canon.py` — Spine-Keys **nur** aus `csv_parser.module_registry` (`get_activity_module_registry_field_keys()` → `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`); kein paralleles Hardcoding. EAV-primär + Migration **057** unverändert. +**Kanon (Code):** `backend/data_layer/activity_data_canon.py` (Repo-Root) — CSV-Modul `activity` vs. EAV-primär; Migration **057**. --- -## 0. CSV-Import & Doppel-EAV (Kanon) - -- Vor Schreibzugriff: **`apply_activity_mapped_column_aliases`** kopiert Werte von `training_parameters.key` auf `source_field`-Spalte, wenn die Spalte leer ist (z. B. `avg_hr` → `hr_avg`). -- **`activity_csv_registry_updates_from_mapped`** ist die **einzige** Quelle für `activity_log`-Kernspalten aus dem Mapping (Keys = `module_registry.activity.fields`); der Executor **liest** keine parallelen `mapped.get("hr_avg")`-Pfade mehr. -- Plausible Zahlen: **`min`/`max`** in den Feld-Specs der Registry (keine HF-speziellen Key-Listen im Executor). -- **`upsert_session_metrics_from_csv_mapped`** schreibt **keine** EAV-Zeilen für Parameter mit gesetztem **`source_field`** (kanonisch `activity_log`). -- **Migration 058:** Entfernt bestehende redundante EAV-Zeilen für alle Parameter mit `source_field`. - ## 1. Produktions-Migrationen (Pflicht) - **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story. @@ -97,7 +89,7 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a Siehe **Phasen A–F** in [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md). Kurz: -- [x] **Phase A (Code-Kanon):** Spine-Felder = Registry `activity.fields`; `insert_activity_csv_minimal` nur Kopf, Metriken via `update_activity_columns` / `activity_csv_registry_updates_from_mapped`; Import-Eval liest Session aus DB. *(Spreadsheet „eine Semantik pro Zeile“ weiterhin fachlich empfohlen.)* +- [ ] **Phase A:** Kanon-Tabelle (eine Quelle pro Semantik). - [ ] **Phase B:** Lesepfad Layer 1 härten (Consumer-Audit). - [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten. - [ ] **Phase D:** Composite-MVP (ein Archetyp E2E). diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index 69f1c06..67c78c7 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -763,6 +763,23 @@ def _import_vitals_baseline( } +def _sf_act(val: Any) -> float | None: + try: + return round(float(val), 1) if val is not None else None + except (TypeError, ValueError): + return None + + +def _activity_hr_bpm(val: Any) -> float | None: + """Plausible Herzfrequenz (Import); größere Werte oft Fehlzuordnung (z. B. Schrittzahl) → NUMERIC-Overflow.""" + v = _sf_act(val) + if v is None: + return None + if v < 20 or v > 280: + return None + return v + + def _looks_like_time_only(s: str) -> bool: t = s.strip() if not t or " " in t: @@ -798,10 +815,7 @@ def _import_activity( run_activity_post_write_hooks_import, update_activity_columns, ) - from data_layer.activity_session_metrics import ( - apply_activity_mapped_column_aliases, - upsert_session_metrics_from_csv_mapped, - ) + from data_layer.activity_session_metrics import upsert_session_metrics_from_csv_mapped rows_total = 0 inserted = 0 @@ -859,6 +873,19 @@ def _import_activity( else: end_str = "" + duration_min = mapped.get("duration_min") + if duration_min is not None: + try: + duration_min = round(float(duration_min), 1) + except (TypeError, ValueError): + duration_min = None + + kcal_a = _sf_act(mapped.get("kcal_active")) + kcal_r = _sf_act(mapped.get("kcal_resting")) + hr_a = _activity_hr_bpm(mapped.get("hr_avg")) + hr_m = _activity_hr_bpm(mapped.get("hr_max")) + dist = _sf_act(mapped.get("distance_km")) + wtype = str(activity_type).strip() iso = date_d.isoformat() _, workout_start_t = normalize_activity_start(start_key) @@ -869,8 +896,6 @@ def _import_activity( training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity( cur, wtype, profile_id ) - mapped = apply_activity_mapped_column_aliases(cur, dict(mapped), training_category, training_type_id) - # Nur Modul-Registry (Zielstruktur) + Mapping — keine parallelen hardcodierten CSV-Schlüssel. registry_updates = activity_csv_registry_updates_from_mapped(mapped) existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t) @@ -879,6 +904,12 @@ def _import_activity( "start_time": workout_start_t, "end_time": end_str or None, "activity_type": wtype, + "duration_min": duration_min, + "kcal_active": kcal_a, + "kcal_resting": kcal_r, + "hr_avg": hr_a, + "hr_max": hr_m, + "distance_km": dist, "training_type_id": training_type_id, "training_category": training_category, "training_subcategory": training_subcategory, @@ -899,12 +930,12 @@ def _import_activity( start_time=workout_start_t, end_time=end_str or None, activity_type=wtype, - duration_min=registry_updates.get("duration_min"), - kcal_active=registry_updates.get("kcal_active"), - kcal_resting=registry_updates.get("kcal_resting"), - hr_avg=registry_updates.get("hr_avg"), - hr_max=registry_updates.get("hr_max"), - distance_km=registry_updates.get("distance_km"), + duration_min=duration_min, + kcal_active=kcal_a, + kcal_resting=kcal_r, + hr_avg=hr_a, + hr_max=hr_m, + distance_km=dist, training_type_id=training_type_id, training_category=training_category, training_subcategory=training_subcategory, @@ -923,12 +954,12 @@ def _import_activity( str(aid), workout_date=iso, training_type_id=training_type_id, - duration_min=registry_updates.get("duration_min"), - hr_avg=registry_updates.get("hr_avg"), - hr_max=registry_updates.get("hr_max"), - distance_km=registry_updates.get("distance_km"), - kcal_active=registry_updates.get("kcal_active"), - kcal_resting=registry_updates.get("kcal_resting"), + duration_min=duration_min, + hr_avg=hr_a, + hr_max=hr_m, + distance_km=dist, + kcal_active=kcal_a, + kcal_resting=kcal_r, ) upsert_session_metrics_from_csv_mapped( cur, diff --git a/backend/csv_parser/module_registry.py b/backend/csv_parser/module_registry.py index 5564fc2..3786327 100644 --- a/backend/csv_parser/module_registry.py +++ b/backend/csv_parser/module_registry.py @@ -65,13 +65,7 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = { "max": 220, "label_de": "Herzfrequenz max (bpm)", }, - "rpe": { - "type": "int", - "required": False, - "min": 1, - "max": 10, - "label_de": "RPE (1–10)", - }, + "rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"}, "notes": {"type": "string", "required": False, "label_de": "Notiz"}, }, "derive_date_from_datetime_field": "start_time", diff --git a/backend/data_layer/activity_data_canon.py b/backend/data_layer/activity_data_canon.py index 9f3ad81..17ec223 100644 --- a/backend/data_layer/activity_data_canon.py +++ b/backend/data_layer/activity_data_canon.py @@ -5,31 +5,30 @@ Single Source für: welche Felder das CSV-/Registry-Modul „activity“ direkt und welche training_parameters primär über EAV laufen (mit optionalem Lesefallback auf Legacy-Spalten). Normative Doku: .claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md - -Phase A: Keine zweite hartcodierte Key-Liste — Registry-Felder kommen ausschließlich aus -``csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields``. """ from __future__ import annotations from typing import Dict, Final -from csv_parser.module_registry import get_module_definition - - -def get_activity_module_registry_field_keys() -> frozenset[str]: - """Keys des Universal-CSV-Moduls ``activity`` (= Spine-Spalten-Namen in activity_log).""" - mod = get_module_definition("activity") - if not mod: - return frozenset() - return frozenset((mod.get("fields") or {}).keys()) - - # ── activity_log: Modul „activity“ (Universal-CSV-Kern) ─────────────────────── -# Ableitung aus module_registry — bei neuen Registry-Feldern hier kein manuelles Update nötig. -ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module_registry_field_keys() - -# Teil-UPDATEs (Import): alle Registry-Kernfelder außer ``date`` (Identität/Duplikat-Key). -ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"} +# Nur diese Keys erscheinen in csv_parser.module_registry MODULE_DEFINITIONS["activity"].fields. +# Alles Weitere: training_parameters + EAV (Import über upsert_session_metrics_from_csv_mapped). +ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = frozenset( + { + "date", + "start_time", + "end_time", + "activity_type", + "duration_min", + "kcal_active", + "kcal_resting", + "distance_km", + "hr_avg", + "hr_max", + "rpe", + "notes", + } +) # Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL. # Lesefallback: activity_log-Spalte unter ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM, falls EAV leer. @@ -59,3 +58,21 @@ ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = { "avg_hr_percent": "avg_hr_percent", "kcal_per_km": "kcal_per_km", } + +# Spalten, die mit training_parameters.source_field (nach Migration 057) noch activity_log abbilden. +# Erweiterte Metriken sind EAV-primär — nicht hier auflisten. +ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = frozenset( + { + "start_time", + "end_time", + "activity_type", + "duration_min", + "kcal_active", + "kcal_resting", + "hr_avg", + "hr_max", + "distance_km", + "rpe", + "notes", + } +) diff --git a/backend/data_layer/activity_persistence_orchestrator.py b/backend/data_layer/activity_persistence_orchestrator.py index 4629343..6085550 100644 --- a/backend/data_layer/activity_persistence_orchestrator.py +++ b/backend/data_layer/activity_persistence_orchestrator.py @@ -15,7 +15,6 @@ from typing import Any, Dict, List, Mapping, Optional from models import ActivityEntry from csv_parser.module_registry import get_module_definition -from data_layer.activity_data_canon import get_activity_module_registry_field_keys logger = logging.getLogger(__name__) @@ -51,8 +50,10 @@ _ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "a def activity_registry_field_keys() -> frozenset[str]: - """Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Phase A: eine Quelle).""" - return get_activity_module_registry_field_keys() + mod = get_module_definition("activity") + if not mod: + return frozenset() + return frozenset((mod.get("fields") or {}).keys()) def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]: @@ -82,20 +83,11 @@ def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict except (TypeError, ValueError): return None - def _within_num_bounds(v: float | int, spec: dict, *, as_float: bool) -> bool: - mn = spec.get("min") - mx = spec.get("max") - if mn is not None: - if as_float and v < float(mn): - return False - if not as_float and v < int(mn): - return False - if mx is not None: - if as_float and v > float(mx): - return False - if not as_float and v > int(mx): - return False - return True + def _hr(v: Any) -> float | None: + x = _sf(v) + if x is None or x < 20 or x > 280: + return None + return x for key, spec in fields.items(): if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE: @@ -109,15 +101,11 @@ def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict continue typ = spec.get("type", "string") if typ == "float": - v = _sf(raw) - if v is not None and not _within_num_bounds(v, spec, as_float=True): - v = None + v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw) if v is not None: out[key] = v elif typ == "int": v = _si(raw) - if v is not None and not _within_num_bounds(v, spec, as_float=False): - v = None if v is not None: out[key] = v elif typ == "datetime": @@ -227,7 +215,7 @@ def insert_activity_csv_minimal( training_subcategory: Any, source: str, ) -> None: - """INSERT activity_log-Zeile (Universal-CSV): Kernspalten im INSERT; optional zusätzliches PATCH.""" + """INSERT minimale activity_log-Zeile (Universal-CSV).""" cur.execute( """ INSERT INTO activity_log ( @@ -296,7 +284,7 @@ def run_activity_post_write_hooks_import( kcal_active: Any, kcal_resting: Any, ) -> None: - """Auto-Eval nach Import (gleiche Transaktion wie Schreibpfad — keine Abhängigkeit vom DB-Read-Timing).""" + """Auto-Eval nach Import. Kein Spalte→EAV-Sync (siehe run_activity_post_write_hooks).""" if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity: try: activity_dict = { diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index 1aa295b..b5ef19d 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -9,10 +9,8 @@ import logging from decimal import Decimal from typing import Any, Dict, List, Mapping, Optional, Sequence -from data_layer.activity_data_canon import ( - ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM, - ACTIVITY_MODULE_REGISTRY_FIELD_KEYS, -) +from csv_parser.module_registry import get_module_definition +from data_layer.activity_data_canon import ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM logger = logging.getLogger(__name__) @@ -31,16 +29,6 @@ ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset( ) -def _parameter_value_stored_in_eav_only(spec: Mapping[str, Any], parameter_key: str) -> bool: - """False = kanonisch activity_log (Modul-Registry oder training_parameters.source_field).""" - if parameter_key in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: - return False - sf = spec.get("source_field") - if sf is not None and str(sf).strip(): - return False - return True - - class ActivitySessionMetricsError(Exception): """Raised by Layer 1; routers map to HTTP (404/400).""" @@ -174,74 +162,6 @@ def resolve_activity_attribute_schema( return merge_parameter_schema_rows(category_rows, type_rows) -def resolve_activity_attribute_schema_for_csv_import( - cur, - training_category: Optional[str], - training_type_id: Optional[int], - mapped: Mapping[str, Any], -) -> List[Dict[str, Any]]: - """ - Wie resolve_activity_attribute_schema, plus alle aktiven training_parameters, deren key in - ``mapped`` vorkommt (nicht Modul-Registry), aber nicht in Kategorie/Typ-Profil — z. B. wenn - ``activity_type_mappings`` fehlt oder der Parameter nur für andere Typen gebucht ist. - - Damit schreibt der Universal-CSV-Import EAV für gültig gemappte Zielfelder wie bei cd29c7d, - sobald der Parameter in ``training_parameters`` existiert. - """ - base = resolve_activity_attribute_schema(cur, training_category, training_type_id) - by_key: Dict[str, Dict[str, Any]] = {s["key"]: s for s in base} - - for k, raw in mapped.items(): - if raw is None or raw == "": - continue - if k in by_key: - continue - if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: - continue - cur.execute( - """ - SELECT - id, - key, - name_de, - name_en, - category AS param_category, - data_type, - unit, - validation_rules, - source_field - FROM training_parameters - WHERE key = %s AND is_active = true - LIMIT 1 - """, - (k,), - ) - row = cur.fetchone() - if not row: - continue - pid = int(row["id"]) - if any(s["training_parameter_id"] == pid for s in by_key.values()): - continue - by_key[k] = { - "training_parameter_id": pid, - "key": row["key"], - "name_de": row["name_de"], - "name_en": row["name_en"], - "param_category": row["param_category"], - "data_type": row["data_type"], - "unit": row["unit"], - "validation_rules": row["validation_rules"] or {}, - "source_field": row["source_field"], - "sort_order": 100_000, - "required": False, - "ui_group": None, - } - - out = list(by_key.values()) - out.sort(key=lambda x: (x.get("sort_order", 0), x["key"])) - return out - - def _validation_rules_dict(raw: Any) -> Dict[str, Any]: if isinstance(raw, dict): return raw @@ -321,45 +241,6 @@ def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any: raise ValueError(data_type) -def apply_activity_mapped_column_aliases_from_schema( - mapped: Mapping[str, Any], - schema: Sequence[Dict[str, Any]], -) -> Dict[str, Any]: - """ - training_parameters.key weicht oft von activity_log-Spalte ab (z. B. avg_hr → hr_avg). - Kopiert Werte auf die Spalte, wenn die Spalte leer ist, damit CSV/Registry activity_log befüllt. - """ - m = dict(mapped) - for s in schema: - sf = s.get("source_field") - if not sf or not str(sf).strip(): - continue - col = str(sf).strip() - pkey = s["key"] - if pkey == col: - continue - col_v = m.get(col) - if col_v is not None and col_v != "": - continue - pk_v = m.get(pkey) - if pk_v is None or pk_v == "": - continue - m[col] = pk_v - return m - - -def apply_activity_mapped_column_aliases( - cur, - mapped: Mapping[str, Any], - training_category: Optional[str], - training_type_id: Optional[int], -) -> Dict[str, Any]: - schema = resolve_activity_attribute_schema_for_csv_import( - cur, training_category, training_type_id, mapped - ) - return apply_activity_mapped_column_aliases_from_schema(mapped, schema) - - def upsert_session_metrics_from_csv_mapped( cur, profile_id: str, @@ -369,10 +250,10 @@ def upsert_session_metrics_from_csv_mapped( training_type_id: Optional[int], ) -> None: """ - EAV für Trainingsparameter aus CSV (nur Keys ohne activity_log-Spalte / ohne source_field). + EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen). - Parameter mit gesetztem source_field sind kanonisch in activity_log — kein EAV-Schreiben (vermeidet - Doppelung zu avg_hr vs. hr_avg o. Ä.). Keys im activity-CSV-Modul werden ebenfalls übersprungen. + Kernfelder (Datum, Start, Distanz, HF, …) schreibt der Executor nach activity_log; + hier keine doppelten EAV-Zeilen für dieselben Registry-Keys. """ cur.execute( "SELECT profile_id FROM activity_log WHERE id = %s", @@ -381,9 +262,9 @@ def upsert_session_metrics_from_csv_mapped( row = cur.fetchone() if not row or str(row["profile_id"]) != str(profile_id): return - schema = resolve_activity_attribute_schema_for_csv_import( - cur, training_category, training_type_id, mapped - ) + mod = get_module_definition("activity") or {} + activity_registry_keys = frozenset((mod.get("fields") or {}).keys()) + schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) for spec in schema: pkey = spec["key"] if pkey not in mapped: @@ -391,7 +272,7 @@ def upsert_session_metrics_from_csv_mapped( raw = mapped[pkey] if raw is None or raw == "": continue - if not _parameter_value_stored_in_eav_only(spec, pkey): + if pkey in activity_registry_keys: continue tid = spec["training_parameter_id"] dt = spec["data_type"] @@ -647,8 +528,6 @@ def replace_activity_session_metrics( if not s["required"]: continue itk = s["key"] - if not _parameter_value_stored_in_eav_only(s, itk): - continue hit = payload_by_key.get(itk) if hit is None or hit.get("value") is None: raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}") @@ -661,11 +540,9 @@ def replace_activity_session_metrics( for item in metrics: k = str(item["parameter_key"]).strip() spec = by_key[k] - if not _parameter_value_stored_in_eav_only(spec, k): - continue val = item.get("value") if val is None: - if spec["required"] and _parameter_value_stored_in_eav_only(spec, k): + if spec["required"]: raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}") continue rules = _validation_rules_dict(spec["validation_rules"]) diff --git a/backend/migrations/058_remove_redundant_eav_for_column_backed_parameters.sql b/backend/migrations/058_remove_redundant_eav_for_column_backed_parameters.sql deleted file mode 100644 index c50b312..0000000 --- a/backend/migrations/058_remove_redundant_eav_for_column_backed_parameters.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Migration 058: EAV-Zeilen entfernen, die nur activity_log-Spalten spiegeln (source_field gesetzt). --- Kanon: merge_column_backed_and_eav_metrics liest diese Werte aus activity_log; Doppelzeilen vermeiden. --- Date: 2026-04-15 - -DELETE FROM activity_session_metrics asm -USING training_parameters tp -WHERE asm.training_parameter_id = tp.id - AND tp.source_field IS NOT NULL - AND trim(tp.source_field) <> ''; - -DO $$ -BEGIN - RAISE NOTICE 'Migration 058: removed EAV rows for column-backed training_parameters (source_field set)'; -END $$; diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py index d6f27db..b8d8bc2 100644 --- a/backend/tests/test_activity_session_metrics.py +++ b/backend/tests/test_activity_session_metrics.py @@ -7,14 +7,10 @@ import pytest from data_layer.activity_session_metrics import ( ActivitySessionMetricsError, - apply_activity_mapped_column_aliases_from_schema, enrich_sessions_with_metrics, merge_column_backed_and_eav_metrics, merge_parameter_schema_rows, - replace_activity_session_metrics, resolve_activity_attribute_schema, - resolve_activity_attribute_schema_for_csv_import, - upsert_session_metrics_from_csv_mapped, _row_value_tuple, _validate_single_value, ) @@ -293,205 +289,6 @@ def test_merge_keeps_eav_only_keys(): assert out[0]["key"] == "custom_param" -def test_apply_mapped_aliases_copies_avg_hr_to_hr_avg(): - schema = [ - { - "key": "avg_hr", - "training_parameter_id": 1, - "source_field": "hr_avg", - "data_type": "integer", - "unit": "bpm", - "validation_rules": {}, - } - ] - out = apply_activity_mapped_column_aliases_from_schema({"avg_hr": 118}, schema) - assert out["avg_hr"] == 118 - assert out["hr_avg"] == 118 - - -def test_apply_mapped_aliases_does_not_overwrite_existing_column(): - schema = [ - { - "key": "avg_hr", - "training_parameter_id": 1, - "source_field": "hr_avg", - "data_type": "integer", - "unit": "bpm", - "validation_rules": {}, - } - ] - out = apply_activity_mapped_column_aliases_from_schema({"avg_hr": 999, "hr_avg": 120}, schema) - assert out["hr_avg"] == 120 - - -@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[]) -def test_resolve_csv_import_adds_mapped_keys_from_training_parameters(mock_resolve): - """Ohne Kategorie/Typ-Profil: gemappte Keys aus training_parameters ergänzen.""" - - class Cur: - def __init__(self): - self.sql = "" - - def execute(self, sql, params=None): - self.sql = sql - self.params = params - - def fetchone(self): - if "training_parameters" in self.sql: - return { - "id": 7, - "key": "cadence", - "name_de": "Kadenz", - "name_en": "Cadence", - "param_category": "physical", - "data_type": "integer", - "unit": "rpm", - "validation_rules": {}, - "source_field": None, - } - return None - - cur = Cur() - out = resolve_activity_attribute_schema_for_csv_import(cur, None, None, {"cadence": 90}) - assert len(out) == 1 - assert out[0]["key"] == "cadence" - assert out[0]["training_parameter_id"] == 7 - mock_resolve.assert_called_once_with(cur, None, None) - - -@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[]) -def test_upsert_csv_inserts_eav_when_only_mapped_training_parameter(mock_resolve): - class Cur: - def __init__(self): - self.asm_inserts = 0 - self.sql = "" - - def execute(self, sql, params=None): - self.sql = sql - self.params = params - if "INSERT INTO activity_session_metrics" in sql: - self.asm_inserts += 1 - - def fetchone(self): - if "activity_log" in self.sql: - return {"profile_id": "00000000-0000-0000-0000-000000000001"} - if "training_parameters" in self.sql: - return { - "id": 7, - "key": "cadence", - "name_de": "K", - "name_en": "K", - "param_category": "physical", - "data_type": "integer", - "unit": None, - "validation_rules": {"min": 0, "max": 300}, - "source_field": None, - } - return None - - cur = Cur() - upsert_session_metrics_from_csv_mapped( - cur, - "00000000-0000-0000-0000-000000000001", - "00000000-0000-0000-0000-000000000002", - {"cadence": 90}, - None, - None, - ) - assert cur.asm_inserts == 1 - - -@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") -def test_upsert_csv_skips_parameter_with_source_field(mock_schema): - """Kein INSERT in activity_session_metrics für Spalten-Parameter (avg_hr → hr_avg).""" - mock_schema.return_value = [ - { - "key": "avg_hr", - "training_parameter_id": 42, - "data_type": "integer", - "validation_rules": {"min": 30, "max": 220}, - "source_field": "hr_avg", - } - ] - - class Cur: - def __init__(self): - self.asm_inserts = 0 - - def execute(self, sql, params=None): - if "INSERT INTO activity_session_metrics" in sql: - self.asm_inserts += 1 - - def fetchone(self): - return {"profile_id": "00000000-0000-0000-0000-000000000001"} - - cur = Cur() - upsert_session_metrics_from_csv_mapped( - cur, - "00000000-0000-0000-0000-000000000001", - "00000000-0000-0000-0000-000000000002", - {"avg_hr": 130}, - "cardio", - 1, - ) - assert cur.asm_inserts == 0 - - -@patch("data_layer.activity_session_metrics.fetch_activity_session_metrics") -@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") -def test_replace_metrics_skips_column_backed_kcal(mock_schema, mock_fetch): - """PUT /metrics: keine EAV-Zeile für kcal_active (liegt in activity_log).""" - pid = str(uuid.uuid4()) - eid = str(uuid.uuid4()) - mock_schema.return_value = [ - { - "training_parameter_id": 1, - "key": "kcal_active", - "data_type": "float", - "validation_rules": {}, - "source_field": "kcal_active", - "required": False, - }, - { - "training_parameter_id": 2, - "key": "custom_reps", - "data_type": "integer", - "validation_rules": {"min": 0}, - "source_field": None, - "required": False, - }, - ] - mock_fetch.return_value = [] - - class Cur: - def __init__(self): - self.asm_inserts = 0 - - def execute(self, sql, params=None): - if "INSERT INTO activity_session_metrics" in sql: - self.asm_inserts += 1 - - def fetchone(self): - return { - "profile_id": pid, - "training_category": "strength", - "training_type_id": 1, - } - - cur = Cur() - replace_activity_session_metrics( - cur, - pid, - eid, - [ - {"parameter_key": "kcal_active", "value": 450.0}, - {"parameter_key": "custom_reps", "value": 12}, - ], - ) - assert cur.asm_inserts == 1 - mock_fetch.assert_called_once_with(cur, eid) - - def test_merge_eav_primary_falls_back_to_legacy_hr_min_column(): """Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min.""" schema = [ @@ -508,18 +305,3 @@ def test_merge_eav_primary_falls_back_to_legacy_hr_min_column(): assert len(out) == 1 assert out[0]["key"] == "min_hr" assert out[0]["value"] == 88 - - -def test_activity_module_registry_field_keys_match_csv_module_definition(): - """Phase A: Kanon-Spine = module_registry „activity“.fields (keine zweite Liste).""" - from csv_parser.module_registry import get_module_definition - from data_layer.activity_data_canon import ( - ACTIVITY_MODULE_REGISTRY_FIELD_KEYS, - get_activity_module_registry_field_keys, - ) - - mod = get_module_definition("activity") - assert mod is not None - expected = frozenset((mod.get("fields") or {}).keys()) - assert get_activity_module_registry_field_keys() == expected - assert ACTIVITY_MODULE_REGISTRY_FIELD_KEYS == expected diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 1ca8551..50ea792 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -96,16 +96,6 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ 'training_subcategory', ]) -/** activity_log-Spalten, die im EntryForm editiert werden (Kurzform). */ -const ACTIVITY_ENTRY_FORM_COLUMNS = new Set([ - 'duration_min', - 'kcal_active', - 'hr_avg', - 'hr_max', - 'rpe', - 'notes', -]) - function empty() { return { date: dayjs().format('YYYY-MM-DD'), @@ -123,9 +113,6 @@ function empty() { function buildMetricsPayload(schema, draft) { const out = [] for (const s of schema) { - if (s.source_field && ACTIVITY_LOG_PAYLOAD_KEYS.has(String(s.source_field))) { - continue - } const raw = draft[s.key] if (s.data_type === 'boolean') { if (raw === '' || raw === null || raw === undefined) { @@ -161,23 +148,14 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) { const schemaList = Array.isArray(schema) ? schema : [] const metricRows = Array.isArray(metrics) ? metrics : [] const schemaKeys = new Set(schemaList.map((s) => s.key)) - const isHeadColumnMetric = (s) => - s && s.source_field && ACTIVITY_LOG_PAYLOAD_KEYS.has(String(s.source_field)) - const schemaForProfileOnly = schemaList.filter((s) => !isHeadColumnMetric(s)) - const orphanMetrics = metricRows.filter( - (row) => - row && - row.key && - !schemaKeys.has(row.key) && - !ACTIVITY_LOG_PAYLOAD_KEYS.has(row.key) - ) + const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.key)) - if (schemaForProfileOnly.length === 0 && orphanMetrics.length === 0) return null + if (schemaList.length === 0 && orphanMetrics.length === 0) return null const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) return (