revert: Wiederherstellung Codezustand von ca8cee9 (ohne Branch-Historie zu überschreiben)
Reverts cd29c7d..026c51b per git revert. Alle zwischenliegenden Commits bleiben in Gitea sichtbar; der Arbeitsbaum entspricht wieder dem Stand von ca8cee9.
Made-with: Cursor
This commit is contained in:
parent
026c51b6b5
commit
06f83e2ffc
|
|
@ -41,7 +41,7 @@ KI / UI / Export
|
||||||
|
|
||||||
### 2.2 `activity_log` (Spine + heiße Skalare)
|
### 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`.
|
**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.
|
**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**.
|
**Erster konkreter Schritt:** Kanon-Tabelle als Checkliste (Spreadsheet oder Gitea-Issue) – **eine Zeile pro Semantik**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
**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)
|
## 1. Produktions-Migrationen (Pflicht)
|
||||||
|
|
||||||
- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story.
|
- **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:
|
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 B:** Lesepfad Layer 1 härten (Consumer-Audit).
|
||||||
- [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten.
|
- [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten.
|
||||||
- [ ] **Phase D:** Composite-MVP (ein Archetyp E2E).
|
- [ ] **Phase D:** Composite-MVP (ein Archetyp E2E).
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def _looks_like_time_only(s: str) -> bool:
|
||||||
t = s.strip()
|
t = s.strip()
|
||||||
if not t or " " in t:
|
if not t or " " in t:
|
||||||
|
|
@ -798,10 +815,7 @@ def _import_activity(
|
||||||
run_activity_post_write_hooks_import,
|
run_activity_post_write_hooks_import,
|
||||||
update_activity_columns,
|
update_activity_columns,
|
||||||
)
|
)
|
||||||
from data_layer.activity_session_metrics import (
|
from data_layer.activity_session_metrics import upsert_session_metrics_from_csv_mapped
|
||||||
apply_activity_mapped_column_aliases,
|
|
||||||
upsert_session_metrics_from_csv_mapped,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows_total = 0
|
rows_total = 0
|
||||||
inserted = 0
|
inserted = 0
|
||||||
|
|
@ -859,6 +873,19 @@ def _import_activity(
|
||||||
else:
|
else:
|
||||||
end_str = ""
|
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()
|
wtype = str(activity_type).strip()
|
||||||
iso = date_d.isoformat()
|
iso = date_d.isoformat()
|
||||||
_, workout_start_t = normalize_activity_start(start_key)
|
_, 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(
|
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
|
||||||
cur, wtype, profile_id
|
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)
|
registry_updates = activity_csv_registry_updates_from_mapped(mapped)
|
||||||
existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t)
|
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,
|
"start_time": workout_start_t,
|
||||||
"end_time": end_str or None,
|
"end_time": end_str or None,
|
||||||
"activity_type": wtype,
|
"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_type_id": training_type_id,
|
||||||
"training_category": training_category,
|
"training_category": training_category,
|
||||||
"training_subcategory": training_subcategory,
|
"training_subcategory": training_subcategory,
|
||||||
|
|
@ -899,12 +930,12 @@ def _import_activity(
|
||||||
start_time=workout_start_t,
|
start_time=workout_start_t,
|
||||||
end_time=end_str or None,
|
end_time=end_str or None,
|
||||||
activity_type=wtype,
|
activity_type=wtype,
|
||||||
duration_min=registry_updates.get("duration_min"),
|
duration_min=duration_min,
|
||||||
kcal_active=registry_updates.get("kcal_active"),
|
kcal_active=kcal_a,
|
||||||
kcal_resting=registry_updates.get("kcal_resting"),
|
kcal_resting=kcal_r,
|
||||||
hr_avg=registry_updates.get("hr_avg"),
|
hr_avg=hr_a,
|
||||||
hr_max=registry_updates.get("hr_max"),
|
hr_max=hr_m,
|
||||||
distance_km=registry_updates.get("distance_km"),
|
distance_km=dist,
|
||||||
training_type_id=training_type_id,
|
training_type_id=training_type_id,
|
||||||
training_category=training_category,
|
training_category=training_category,
|
||||||
training_subcategory=training_subcategory,
|
training_subcategory=training_subcategory,
|
||||||
|
|
@ -923,12 +954,12 @@ def _import_activity(
|
||||||
str(aid),
|
str(aid),
|
||||||
workout_date=iso,
|
workout_date=iso,
|
||||||
training_type_id=training_type_id,
|
training_type_id=training_type_id,
|
||||||
duration_min=registry_updates.get("duration_min"),
|
duration_min=duration_min,
|
||||||
hr_avg=registry_updates.get("hr_avg"),
|
hr_avg=hr_a,
|
||||||
hr_max=registry_updates.get("hr_max"),
|
hr_max=hr_m,
|
||||||
distance_km=registry_updates.get("distance_km"),
|
distance_km=dist,
|
||||||
kcal_active=registry_updates.get("kcal_active"),
|
kcal_active=kcal_a,
|
||||||
kcal_resting=registry_updates.get("kcal_resting"),
|
kcal_resting=kcal_r,
|
||||||
)
|
)
|
||||||
upsert_session_metrics_from_csv_mapped(
|
upsert_session_metrics_from_csv_mapped(
|
||||||
cur,
|
cur,
|
||||||
|
|
|
||||||
|
|
@ -65,13 +65,7 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"max": 220,
|
"max": 220,
|
||||||
"label_de": "Herzfrequenz max (bpm)",
|
"label_de": "Herzfrequenz max (bpm)",
|
||||||
},
|
},
|
||||||
"rpe": {
|
"rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"},
|
||||||
"type": "int",
|
|
||||||
"required": False,
|
|
||||||
"min": 1,
|
|
||||||
"max": 10,
|
|
||||||
"label_de": "RPE (1–10)",
|
|
||||||
},
|
|
||||||
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
|
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
|
||||||
},
|
},
|
||||||
"derive_date_from_datetime_field": "start_time",
|
"derive_date_from_datetime_field": "start_time",
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Dict, Final
|
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) ───────────────────────
|
# ── activity_log: Modul „activity“ (Universal-CSV-Kern) ───────────────────────
|
||||||
# Ableitung aus module_registry — bei neuen Registry-Feldern hier kein manuelles Update nötig.
|
# Nur diese Keys erscheinen in csv_parser.module_registry MODULE_DEFINITIONS["activity"].fields.
|
||||||
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module_registry_field_keys()
|
# Alles Weitere: training_parameters + EAV (Import über upsert_session_metrics_from_csv_mapped).
|
||||||
|
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = frozenset(
|
||||||
# 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"}
|
"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.
|
# 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.
|
# 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",
|
"avg_hr_percent": "avg_hr_percent",
|
||||||
"kcal_per_km": "kcal_per_km",
|
"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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from typing import Any, Dict, List, Mapping, Optional
|
||||||
from models import ActivityEntry
|
from models import ActivityEntry
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
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__)
|
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]:
|
def activity_registry_field_keys() -> frozenset[str]:
|
||||||
"""Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Phase A: eine Quelle)."""
|
mod = get_module_definition("activity")
|
||||||
return get_activity_module_registry_field_keys()
|
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]:
|
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):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _within_num_bounds(v: float | int, spec: dict, *, as_float: bool) -> bool:
|
def _hr(v: Any) -> float | None:
|
||||||
mn = spec.get("min")
|
x = _sf(v)
|
||||||
mx = spec.get("max")
|
if x is None or x < 20 or x > 280:
|
||||||
if mn is not None:
|
return None
|
||||||
if as_float and v < float(mn):
|
return x
|
||||||
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
|
|
||||||
|
|
||||||
for key, spec in fields.items():
|
for key, spec in fields.items():
|
||||||
if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE:
|
if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE:
|
||||||
|
|
@ -109,15 +101,11 @@ def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict
|
||||||
continue
|
continue
|
||||||
typ = spec.get("type", "string")
|
typ = spec.get("type", "string")
|
||||||
if typ == "float":
|
if typ == "float":
|
||||||
v = _sf(raw)
|
v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw)
|
||||||
if v is not None and not _within_num_bounds(v, spec, as_float=True):
|
|
||||||
v = None
|
|
||||||
if v is not None:
|
if v is not None:
|
||||||
out[key] = v
|
out[key] = v
|
||||||
elif typ == "int":
|
elif typ == "int":
|
||||||
v = _si(raw)
|
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:
|
if v is not None:
|
||||||
out[key] = v
|
out[key] = v
|
||||||
elif typ == "datetime":
|
elif typ == "datetime":
|
||||||
|
|
@ -227,7 +215,7 @@ def insert_activity_csv_minimal(
|
||||||
training_subcategory: Any,
|
training_subcategory: Any,
|
||||||
source: str,
|
source: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""INSERT activity_log-Zeile (Universal-CSV): Kernspalten im INSERT; optional zusätzliches PATCH."""
|
"""INSERT minimale activity_log-Zeile (Universal-CSV)."""
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO activity_log (
|
INSERT INTO activity_log (
|
||||||
|
|
@ -296,7 +284,7 @@ def run_activity_post_write_hooks_import(
|
||||||
kcal_active: Any,
|
kcal_active: Any,
|
||||||
kcal_resting: Any,
|
kcal_resting: Any,
|
||||||
) -> None:
|
) -> 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:
|
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
||||||
try:
|
try:
|
||||||
activity_dict = {
|
activity_dict = {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,8 @@ import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
from data_layer.activity_data_canon import (
|
from csv_parser.module_registry import get_module_definition
|
||||||
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM,
|
from data_layer.activity_data_canon import ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM
|
||||||
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class ActivitySessionMetricsError(Exception):
|
||||||
"""Raised by Layer 1; routers map to HTTP (404/400)."""
|
"""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)
|
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]:
|
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
return raw
|
return raw
|
||||||
|
|
@ -321,45 +241,6 @@ def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
|
||||||
raise ValueError(data_type)
|
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(
|
def upsert_session_metrics_from_csv_mapped(
|
||||||
cur,
|
cur,
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
|
|
@ -369,10 +250,10 @@ def upsert_session_metrics_from_csv_mapped(
|
||||||
training_type_id: Optional[int],
|
training_type_id: Optional[int],
|
||||||
) -> None:
|
) -> 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
|
Kernfelder (Datum, Start, Distanz, HF, …) schreibt der Executor nach activity_log;
|
||||||
Doppelung zu avg_hr vs. hr_avg o. Ä.). Keys im activity-CSV-Modul werden ebenfalls übersprungen.
|
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys.
|
||||||
"""
|
"""
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT profile_id FROM activity_log WHERE id = %s",
|
"SELECT profile_id FROM activity_log WHERE id = %s",
|
||||||
|
|
@ -381,9 +262,9 @@ def upsert_session_metrics_from_csv_mapped(
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
if not row or str(row["profile_id"]) != str(profile_id):
|
||||||
return
|
return
|
||||||
schema = resolve_activity_attribute_schema_for_csv_import(
|
mod = get_module_definition("activity") or {}
|
||||||
cur, training_category, training_type_id, mapped
|
activity_registry_keys = frozenset((mod.get("fields") or {}).keys())
|
||||||
)
|
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
|
||||||
for spec in schema:
|
for spec in schema:
|
||||||
pkey = spec["key"]
|
pkey = spec["key"]
|
||||||
if pkey not in mapped:
|
if pkey not in mapped:
|
||||||
|
|
@ -391,7 +272,7 @@ def upsert_session_metrics_from_csv_mapped(
|
||||||
raw = mapped[pkey]
|
raw = mapped[pkey]
|
||||||
if raw is None or raw == "":
|
if raw is None or raw == "":
|
||||||
continue
|
continue
|
||||||
if not _parameter_value_stored_in_eav_only(spec, pkey):
|
if pkey in activity_registry_keys:
|
||||||
continue
|
continue
|
||||||
tid = spec["training_parameter_id"]
|
tid = spec["training_parameter_id"]
|
||||||
dt = spec["data_type"]
|
dt = spec["data_type"]
|
||||||
|
|
@ -647,8 +528,6 @@ def replace_activity_session_metrics(
|
||||||
if not s["required"]:
|
if not s["required"]:
|
||||||
continue
|
continue
|
||||||
itk = s["key"]
|
itk = s["key"]
|
||||||
if not _parameter_value_stored_in_eav_only(s, itk):
|
|
||||||
continue
|
|
||||||
hit = payload_by_key.get(itk)
|
hit = payload_by_key.get(itk)
|
||||||
if hit is None or hit.get("value") is None:
|
if hit is None or hit.get("value") is None:
|
||||||
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
|
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
|
||||||
|
|
@ -661,11 +540,9 @@ def replace_activity_session_metrics(
|
||||||
for item in metrics:
|
for item in metrics:
|
||||||
k = str(item["parameter_key"]).strip()
|
k = str(item["parameter_key"]).strip()
|
||||||
spec = by_key[k]
|
spec = by_key[k]
|
||||||
if not _parameter_value_stored_in_eav_only(spec, k):
|
|
||||||
continue
|
|
||||||
val = item.get("value")
|
val = item.get("value")
|
||||||
if val is None:
|
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}")
|
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}")
|
||||||
continue
|
continue
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
rules = _validation_rules_dict(spec["validation_rules"])
|
||||||
|
|
|
||||||
|
|
@ -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 $$;
|
|
||||||
|
|
@ -7,14 +7,10 @@ import pytest
|
||||||
|
|
||||||
from data_layer.activity_session_metrics import (
|
from data_layer.activity_session_metrics import (
|
||||||
ActivitySessionMetricsError,
|
ActivitySessionMetricsError,
|
||||||
apply_activity_mapped_column_aliases_from_schema,
|
|
||||||
enrich_sessions_with_metrics,
|
enrich_sessions_with_metrics,
|
||||||
merge_column_backed_and_eav_metrics,
|
merge_column_backed_and_eav_metrics,
|
||||||
merge_parameter_schema_rows,
|
merge_parameter_schema_rows,
|
||||||
replace_activity_session_metrics,
|
|
||||||
resolve_activity_attribute_schema,
|
resolve_activity_attribute_schema,
|
||||||
resolve_activity_attribute_schema_for_csv_import,
|
|
||||||
upsert_session_metrics_from_csv_mapped,
|
|
||||||
_row_value_tuple,
|
_row_value_tuple,
|
||||||
_validate_single_value,
|
_validate_single_value,
|
||||||
)
|
)
|
||||||
|
|
@ -293,205 +289,6 @@ def test_merge_keeps_eav_only_keys():
|
||||||
assert out[0]["key"] == "custom_param"
|
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():
|
def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
|
||||||
"""Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min."""
|
"""Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min."""
|
||||||
schema = [
|
schema = [
|
||||||
|
|
@ -508,18 +305,3 @@ def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
assert out[0]["key"] == "min_hr"
|
assert out[0]["key"] == "min_hr"
|
||||||
assert out[0]["value"] == 88
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -96,16 +96,6 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
|
||||||
'training_subcategory',
|
'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() {
|
function empty() {
|
||||||
return {
|
return {
|
||||||
date: dayjs().format('YYYY-MM-DD'),
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
|
@ -123,9 +113,6 @@ function empty() {
|
||||||
function buildMetricsPayload(schema, draft) {
|
function buildMetricsPayload(schema, draft) {
|
||||||
const out = []
|
const out = []
|
||||||
for (const s of schema) {
|
for (const s of schema) {
|
||||||
if (s.source_field && ACTIVITY_LOG_PAYLOAD_KEYS.has(String(s.source_field))) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const raw = draft[s.key]
|
const raw = draft[s.key]
|
||||||
if (s.data_type === 'boolean') {
|
if (s.data_type === 'boolean') {
|
||||||
if (raw === '' || raw === null || raw === undefined) {
|
if (raw === '' || raw === null || raw === undefined) {
|
||||||
|
|
@ -161,23 +148,14 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
||||||
const schemaList = Array.isArray(schema) ? schema : []
|
const schemaList = Array.isArray(schema) ? schema : []
|
||||||
const metricRows = Array.isArray(metrics) ? metrics : []
|
const metricRows = Array.isArray(metrics) ? metrics : []
|
||||||
const schemaKeys = new Set(schemaList.map((s) => s.key))
|
const schemaKeys = new Set(schemaList.map((s) => s.key))
|
||||||
const isHeadColumnMetric = (s) =>
|
const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.key))
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
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 }))
|
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
||||||
{schemaForProfileOnly.map((s) => (
|
{schemaList.map((s) => (
|
||||||
<div key={s.key} className="form-row">
|
<div key={s.key} className="form-row">
|
||||||
<label className="form-label">
|
<label className="form-label">
|
||||||
{s.name_de}
|
{s.name_de}
|
||||||
|
|
@ -568,27 +546,6 @@ export default function ActivityPage() {
|
||||||
setMetricDraft(m)
|
setMetricDraft(m)
|
||||||
}, [sessionDetail])
|
}, [sessionDetail])
|
||||||
|
|
||||||
/** Nach GET /activity/:id: Kopf-Spalten ins EntryForm, wenn die Listenzeile leer war (Parameter-Keys vs. Spalten). */
|
|
||||||
useEffect(() => {
|
|
||||||
const h = sessionDetail?.header
|
|
||||||
if (!h?.id || !editing?.id) return
|
|
||||||
if (String(h.id) !== String(editing.id)) return
|
|
||||||
setEditing((prev) => {
|
|
||||||
if (String(h.id) !== String(prev.id)) return prev
|
|
||||||
let changed = false
|
|
||||||
const next = { ...prev }
|
|
||||||
for (const col of ACTIVITY_ENTRY_FORM_COLUMNS) {
|
|
||||||
const cur = next[col]
|
|
||||||
const empty = cur === null || cur === undefined || cur === ''
|
|
||||||
if (empty && h[col] != null && h[col] !== '') {
|
|
||||||
next[col] = h[col]
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed ? next : prev
|
|
||||||
})
|
|
||||||
}, [sessionDetail, editing?.id])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -631,18 +588,8 @@ export default function ActivityPage() {
|
||||||
for (const s of sessionDetail?.schema || []) {
|
for (const s of sessionDetail?.schema || []) {
|
||||||
const col = s.source_field
|
const col = s.source_field
|
||||||
if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue
|
if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue
|
||||||
const useFormColumn = ACTIVITY_ENTRY_FORM_COLUMNS.has(col)
|
if (!(s.key in metricDraft)) continue
|
||||||
if (!useFormColumn && !(s.key in metricDraft)) continue
|
const raw = metricDraft[s.key]
|
||||||
let raw
|
|
||||||
if (useFormColumn) {
|
|
||||||
const fromForm = editing[col]
|
|
||||||
const fromDraft = metricDraft[s.key]
|
|
||||||
const formEmpty = fromForm === null || fromForm === undefined || fromForm === ''
|
|
||||||
const draftEmpty = fromDraft === null || fromDraft === undefined || fromDraft === ''
|
|
||||||
raw = !formEmpty ? fromForm : !draftEmpty ? fromDraft : fromForm
|
|
||||||
} else {
|
|
||||||
raw = metricDraft[s.key]
|
|
||||||
}
|
|
||||||
const rawStr = raw === null || raw === undefined ? '' : String(raw).trim()
|
const rawStr = raw === null || raw === undefined ? '' : String(raw).trim()
|
||||||
if (rawStr === '') {
|
if (rawStr === '') {
|
||||||
payload[col] = null
|
payload[col] = null
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user