diff --git a/.claude/docs/README.md b/.claude/docs/README.md index eb9d192..c2834d7 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -113,6 +113,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp | `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 | | `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch | | `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste | +| `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` | Session-Metriken EAV, Attributprofile, Layer-1, Prod-Migration | | `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) | --- diff --git a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md new file mode 100644 index 0000000..c0581bf --- /dev/null +++ b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md @@ -0,0 +1,122 @@ +# Activity Session Metrics (EAV) – Umsetzungs- & Agent-Guide + +**Stand:** 2026-04-14 +**Status:** Kern-Backend (Migration 054, Layer 1, Admin- & Nutzer-API) umgesetzt; Admin-UI & CSV-Mapping folgen. +**Ziel:** Sportspezifische **Attributprofile** (Kategorie + optional Trainingstyp-Override) administrierbar; Messwerte pro Session in **EAV**; **alle Auswertungen** sollen künftig über **Layer 1** (`data_layer`) laufen. + +--- + +## 1. Produktions-Migrationen (Pflicht) + +- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story. +- Neue Migrationen: **`backend/migrations/054_*.sql`** (nächste freie Nummer nach 053 einhalten). +- **Prod-Checkliste vor Deploy:** + 1. Backup / Snapshot der DB. + 2. Migration auf **Kopie** der Prod-DB laufen lassen; Container-Start (`db_init`) verifizieren. + 3. Stichprobe: `activity_log`-Zeilen unverändert; neue Tabellen leer oder nur Seed. +- **Datenhaltung:** Bestehende Spalten in `activity_log` bleiben **Quelle für Alt-Daten**; EAV (`activity_session_metrics`) ist der **kanonische Ort für konfigurierte Session-Metriken**, sobald geschrieben. Backfill Altspalten → EAV ist **separater Schritt** (siehe §6). + +--- + +## 2. Datenmodell (Ist nach Migration 054) + +| Tabelle | Zweck | +|---------|--------| +| `training_parameters` | Katalog messbarer Größen (`key`, `data_type`, `unit`, `validation_rules`, …) – bereits Migration 013; Admin-API ergänzt. | +| `training_category_parameter` | Welche Parameter für welche **`training_types.category`** (z. B. `cardio`) gelten: `sort_order`, `required`, `ui_group`. | +| `training_type_parameter` | Zusatzparameter oder **Overrides** pro **`training_types.id`**: `sort_order`, `required`, `ui_group` (NULL = von Kategorie erben). | +| `activity_session_metrics` | EAV: `(activity_log_id, training_parameter_id)` eindeutig; genau eine Wertspalte `value_num` / `value_int` / `value_text` / `value_bool`. | +| `activity_log` | **Neu:** `started_at`, `ended_at` (`TIMESTAMPTZ`, nullable) – für spätere Dedupe/Intervalle; **kein** Pflichtfeld in v1. | + +**Merge-Logik effektives Schema** (Layer 1, eine Funktion): + +1. Kategorie ermitteln: aus Zeile `training_category` oder aus `training_types.category` via `training_type_id`. +2. Basis = alle Zeilen `training_category_parameter` für diese Kategorie, Join auf `training_parameters` (aktiv). +3. Für jeden Eintrag in `training_type_parameter` zum gewählten Typ: gleiche `training_parameter_id` → Overrides anwenden; nur im Typ vorhanden → anhängen. +4. Sortierung: `sort_order` aufsteigend, dann `key`. + +--- + +## 3. Layer 1 – Kanonische Module + +| Modul | Pfad | Aufgabe | +|-------|------|---------| +| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`. | + +**Regeln für Agenten:** + +- **Keine** zweite Implementierung derselben Merge- oder Validierungslogik in Routern. +- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`). +- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`. + +--- + +## 4. API (Ist / geplant) + +### Admin (`require_admin`) + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET/POST | `/api/admin/training-parameters` | Katalog lesen / Parameter anlegen | +| PUT/DELETE | `/api/admin/training-parameters/{id}` | Aktualisieren / Soft-deaktivieren (`is_active`) | +| GET | `/api/admin/training-category-parameters?category=` | Zuordnungen Kategorie | +| POST | `/api/admin/training-category-parameters` | Zuordnung anlegen | +| DELETE | `/api/admin/training-category-parameters/{id}` | Zuordnung entfernen | +| GET | `/api/admin/training-type-parameters?training_type_id=` | Zuordnungen Typ | +| POST | `/api/admin/training-type-parameters` | Zuordnung anlegen | +| DELETE | `/api/admin/training-type-parameters/{id}` | Zuordnung entfernen | + +Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_activity_attribute_profiles.py`. + +### Nutzer (`require_auth`) + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET | `/api/activity/{eid}` | Session-Kopf + `schema` + `metrics` (Layer 1) | +| PUT | `/api/activity/{eid}/metrics` | **Voller Ersatz** der EAV-Metriken für diese Session (Liste `{parameter_key, value}`) | + +`ActivityEntry` unverändert für bestehende Create/Update-Routen; optionale Erweiterung um `started_at`/`ended_at` in späterem Schritt. + +--- + +## 5. Agent-Checkliste (nächste Iterationen) + +- [ ] Admin-UI: Matrix Kategorie / Trainingstyp ↔ Parameter. +- [ ] `/activity` Frontend: dynamische Felder aus `GET /api/activity/{id}`. +- [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor). +- [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`. +- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue). + +--- + +## 6. Backfill (nicht in Migration 054) + +Separates Skript oder Migration **055+**, wenn fachlich freigegeben: + +- Pro aktivem `training_parameter` mit gesetztem `source_field`: Wert aus `activity_log` lesen, in EAV schreiben, wenn noch keine Zeile existiert. +- Idempotent (`ON CONFLICT DO NOTHING` oder Upsert-Regel dokumentieren). + +--- + +## 7. Automatische Tests (pytest, ohne DB) + +Aus **`backend/`**: + +```bash +python -m pytest tests/test_activity_session_metrics.py -v +``` + +Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_validate_single_value`), `resolve_activity_attribute_schema` mit Mock-Cursor, `enrich_sessions_with_metrics` mit Mock-Cursor. + +--- + +## 8. Referenzen + +- Migration 013: `training_parameters` +- Migration 004/014: `training_types`, `activity_log`-Erweiterungen +- Pattern Admin-Katalog: `routers/admin_reference_value_types.py` +- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data` + +--- + +**Version:** 1.1 · Bei Schema- oder API-Änderungen dieses Dokument und ggf. `CLAUDE.md` Kurzverweis aktualisieren. diff --git a/CLAUDE.md b/CLAUDE.md index 3e6b4c9..0de72bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ > | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** | > | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** | > | **Agent-Einstieg** | **`.claude/README.md`** | +> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** | ## Claude Code Verantwortlichkeiten @@ -115,6 +116,12 @@ frontend/src/ - **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75 - **`nutrition_score`:** Registry in `backend/placeholder_registrations/nutrition_score.py`, Import in `placeholder_registrations/__init__.py`; Legacy-Duplikat unter „Scores“ im Platzhalter-Katalog entfernt. +### Updates (14.04.2026 - Activity Session Metrics EAV, Kern-Backend) + +- **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`). +- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable). +- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt). + ### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score) - **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **Mifflin–St Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance). diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index 4b57358..a1afd7e 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -22,6 +22,7 @@ from typing import Dict, List, Optional, Any from datetime import datetime, timedelta, date, time import statistics from db import get_db, get_cursor, r2d +from data_layer.activity_session_metrics import enrich_sessions_with_metrics from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates @@ -1112,6 +1113,7 @@ def get_training_sessions_recent_weeks_data( (profile_id, cutoff), ) rows = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, rows) if not rows: return { @@ -1141,6 +1143,7 @@ def get_training_sessions_recent_weeks_data( hr_m = r.get("hr_max") by_week[wk].append( { + "id": str(r["id"]), "date": d, "start_time": str(r["start_time"]) if r.get("start_time") is not None else None, "activity_type": r.get("activity_type"), @@ -1151,6 +1154,7 @@ def get_training_sessions_recent_weeks_data( "hr_avg": int(hr_a) if hr_a is not None else None, "hr_max": int(hr_m) if hr_m is not None else None, "rpe": int(r["rpe"]) if r.get("rpe") is not None else None, + "session_metrics": r.get("session_metrics", []), } ) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py new file mode 100644 index 0000000..03aef2b --- /dev/null +++ b/backend/data_layer/activity_session_metrics.py @@ -0,0 +1,364 @@ +""" +Activity session metrics (EAV) and resolved attribute schema — Layer 1. + +See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Dict, List, Optional, Sequence + + +class ActivitySessionMetricsError(Exception): + """Raised by Layer 1; routers map to HTTP (404/400).""" + + def __init__(self, status_code: int, detail: str): + self.status_code = status_code + self.detail = detail + super().__init__(detail) + + +def _effective_training_category( + cur, training_category: Optional[str], training_type_id: Optional[int] +) -> Optional[str]: + if training_category: + return training_category.strip() or None + if training_type_id is None: + return None + cur.execute("SELECT category FROM training_types WHERE id = %s", (training_type_id,)) + row = cur.fetchone() + if row and row.get("category"): + return row["category"] + return None + + +def merge_parameter_schema_rows( + category_rows: Sequence[Dict[str, Any]], + type_rows: Sequence[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Pure merge: category assignments + type assignments → sorted schema list. + Row shapes match SELECTs in resolve_activity_attribute_schema (cat_sort / typ_* aliases). + """ + merged: Dict[int, Dict[str, Any]] = {} + + for r in category_rows: + pid = r["training_parameter_id"] + merged[pid] = { + "training_parameter_id": pid, + "key": r["key"], + "name_de": r["name_de"], + "name_en": r["name_en"], + "param_category": r["param_category"], + "data_type": r["data_type"], + "unit": r["unit"], + "validation_rules": r["validation_rules"] or {}, + "source_field": r["source_field"], + "sort_order": r["cat_sort"], + "required": bool(r["cat_required"]), + "ui_group": r["cat_ui_group"], + } + + for r in type_rows: + pid = r["training_parameter_id"] + base = merged.get(pid) + if base is None: + merged[pid] = { + "training_parameter_id": pid, + "key": r["key"], + "name_de": r["name_de"], + "name_en": r["name_en"], + "param_category": r["param_category"], + "data_type": r["data_type"], + "unit": r["unit"], + "validation_rules": r["validation_rules"] or {}, + "source_field": r["source_field"], + "sort_order": r["typ_sort"] if r["typ_sort"] is not None else 0, + "required": bool(r["typ_required"]) if r["typ_required"] is not None else False, + "ui_group": r["typ_ui_group"], + } + else: + if r["typ_sort"] is not None: + base["sort_order"] = r["typ_sort"] + if r["typ_required"] is not None: + base["required"] = bool(r["typ_required"]) + if r["typ_ui_group"] is not None: + base["ui_group"] = r["typ_ui_group"] + + out = list(merged.values()) + out.sort(key=lambda x: (x["sort_order"], x["key"])) + return out + + +def resolve_activity_attribute_schema( + cur, + training_category: Optional[str], + training_type_id: Optional[int], +) -> List[Dict[str, Any]]: + """ + Merged parameter definitions for UI / validation (category base + type overrides/additions). + Sorted by sort_order, then key. + """ + cat = _effective_training_category(cur, training_category, training_type_id) + category_rows: List[Dict[str, Any]] = [] + type_rows: List[Dict[str, Any]] = [] + + if cat: + cur.execute( + """ + SELECT + tcp.training_parameter_id, + tcp.sort_order AS cat_sort, + tcp.required AS cat_required, + tcp.ui_group AS cat_ui_group, + tp.key, tp.name_de, tp.name_en, tp.category AS param_category, + tp.data_type, tp.unit, tp.validation_rules, tp.source_field + FROM training_category_parameter tcp + JOIN training_parameters tp ON tp.id = tcp.training_parameter_id + WHERE tcp.training_category = %s AND tp.is_active = true + """, + (cat,), + ) + category_rows = list(cur.fetchall()) + + if training_type_id is not None: + cur.execute( + """ + SELECT + ttp.training_parameter_id, + ttp.sort_order AS typ_sort, + ttp.required AS typ_required, + ttp.ui_group AS typ_ui_group, + tp.key, tp.name_de, tp.name_en, tp.category AS param_category, + tp.data_type, tp.unit, tp.validation_rules, tp.source_field + FROM training_type_parameter ttp + JOIN training_parameters tp ON tp.id = ttp.training_parameter_id + WHERE ttp.training_type_id = %s AND tp.is_active = true + """, + (training_type_id,), + ) + type_rows = list(cur.fetchall()) + + return merge_parameter_schema_rows(category_rows, type_rows) + + +def _validation_rules_dict(raw: Any) -> Dict[str, Any]: + if isinstance(raw, dict): + return raw + return {} + + +def _validate_single_value(data_type: str, value: Any, rules: Dict[str, Any]) -> None: + if data_type == "integer": + if not isinstance(value, int) or isinstance(value, bool): + raise ActivitySessionMetricsError(400, f"Erwartet integer, erhalten: {type(value).__name__}") + if "min" in rules and value < rules["min"]: + raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})") + if "max" in rules and value > rules["max"]: + raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})") + elif data_type == "float": + if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)): + raise ActivitySessionMetricsError(400, f"Erwartet Zahl, erhalten: {type(value).__name__}") + v = float(value) + if "min" in rules and v < float(rules["min"]): + raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})") + if "max" in rules and v > float(rules["max"]): + raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})") + elif data_type == "string": + if not isinstance(value, str): + raise ActivitySessionMetricsError(400, f"Erwartet string, erhalten: {type(value).__name__}") + if rules.get("not_empty") and not value.strip(): + raise ActivitySessionMetricsError(400, "Leerer String nicht erlaubt") + if "max_length" in rules and len(value) > int(rules["max_length"]): + raise ActivitySessionMetricsError(400, f"String zu lang (max {rules['max_length']})") + allowed = rules.get("allowed_values") + if allowed and value not in allowed: + raise ActivitySessionMetricsError(400, "Wert nicht in erlaubter Menge") + elif data_type == "boolean": + if not isinstance(value, bool): + raise ActivitySessionMetricsError(400, f"Erwartet boolean, erhalten: {type(value).__name__}") + else: + raise ActivitySessionMetricsError(400, f"Unbekannter data_type: {data_type}") + + +def _row_value_tuple(data_type: str, value: Any) -> tuple: + if data_type == "integer": + return (None, int(value), None, None) + if data_type == "float": + return (float(value), None, None, None) + if data_type == "string": + return (None, None, str(value), None) + if data_type == "boolean": + return (None, None, None, bool(value)) + raise ValueError(data_type) + + +def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT + m.id, + m.activity_log_id, + m.training_parameter_id, + m.value_num, + m.value_int, + m.value_text, + m.value_bool, + tp.key, + tp.data_type, + tp.unit + FROM activity_session_metrics m + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE m.activity_log_id = %s + ORDER BY tp.key + """, + (activity_log_id,), + ) + rows = cur.fetchall() + out: List[Dict[str, Any]] = [] + for r in rows: + dt = r["data_type"] + if dt == "integer": + val = int(r["value_int"]) if r["value_int"] is not None else None + elif dt == "float": + val = float(r["value_num"]) if r["value_num"] is not None else None + elif dt == "string": + val = r["value_text"] + else: + val = r["value_bool"] + out.append( + { + "training_parameter_id": r["training_parameter_id"], + "key": r["key"], + "data_type": dt, + "unit": r["unit"], + "value": val, + } + ) + return out + + +def replace_activity_session_metrics( + cur, + profile_id: str, + activity_log_id: str, + metrics: Sequence[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Full replace of EAV rows for this session. metrics: [{ "parameter_key": str, "value": ... }, ...] + """ + cur.execute( + """ + SELECT id, profile_id, training_category, training_type_id + FROM activity_log WHERE id = %s + """, + (activity_log_id,), + ) + row = cur.fetchone() + if not row or str(row["profile_id"]) != str(profile_id): + raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden") + + schema = resolve_activity_attribute_schema( + cur, row.get("training_category"), row.get("training_type_id") + ) + by_key = {s["key"]: s for s in schema} + payload_keys = set() + for item in metrics: + raw_k = item.get("parameter_key") + if raw_k is None or not str(raw_k).strip(): + raise ActivitySessionMetricsError(400, "parameter_key fehlt") + k = str(raw_k).strip() + if k not in by_key: + raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}") + payload_keys.add(k) + + for s in schema: + if s["required"] and s["key"] not in payload_keys: + raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {s['key']}") + + cur.execute( + "DELETE FROM activity_session_metrics WHERE activity_log_id = %s", + (activity_log_id,), + ) + + for item in metrics: + k = str(item["parameter_key"]).strip() + spec = by_key[k] + rules = _validation_rules_dict(spec["validation_rules"]) + _validate_single_value(spec["data_type"], item.get("value"), rules) + vn, vi, vt, vb = _row_value_tuple(spec["data_type"], item["value"]) + cur.execute( + """ + INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at + ) VALUES (%s, %s, %s, %s, %s, %s, NOW()) + """, + (activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb), + ) + + return fetch_activity_session_metrics(cur, activity_log_id) + + +def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str) -> Dict[str, Any]: + cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,)) + row = cur.fetchone() + if not row or str(row["profile_id"]) != str(profile_id): + raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden") + + header = dict(row) + schema = resolve_activity_attribute_schema( + cur, header.get("training_category"), header.get("training_type_id") + ) + metrics = fetch_activity_session_metrics(cur, activity_log_id) + return { + "header": header, + "schema": schema, + "metrics": metrics, + } + + +def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None: + """Mutates each session dict: adds key 'session_metrics' (list) when sessions non-empty.""" + if not sessions: + return + ids = [str(s["id"]) for s in sessions if s.get("id")] + if not ids: + return + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT + m.activity_log_id, + tp.key, + tp.data_type, + tp.unit, + m.value_num, + m.value_int, + m.value_text, + m.value_bool + FROM activity_session_metrics m + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE m.activity_log_id IN ({ph}) + ORDER BY m.activity_log_id, tp.key + """, + ids, + ) + by_act: Dict[str, List[Dict[str, Any]]] = {} + for r in cur.fetchall(): + aid = str(r["activity_log_id"]) + dt = r["data_type"] + if dt == "integer": + val = int(r["value_int"]) if r["value_int"] is not None else None + elif dt == "float": + val = float(r["value_num"]) if r["value_num"] is not None else None + elif dt == "string": + val = r["value_text"] + else: + val = r["value_bool"] + by_act.setdefault(aid, []).append( + {"key": r["key"], "data_type": dt, "unit": r["unit"], "value": val} + ) + for s in sessions: + aid = str(s.get("id")) + s["session_metrics"] = by_act.get(aid, []) diff --git a/backend/main.py b/backend/main.py index 825cdb8..3a0826b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,6 +36,7 @@ from routers import reference_values # Persönliche Referenzwerte (Profil) from routers import admin_reference_value_types # Admin: Referenzwert-Typen from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser +from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -128,6 +129,8 @@ app.include_router(admin_reference_value_types.router) # /api/admin/reference-v app.include_router(app_dashboard.router) # /api/app/dashboard-layout app.include_router(csv_import.router) # /api/csv/* (Issue #21) app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21) +app.include_router(admin_training_parameters.router) # /api/admin/training-parameters +app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/054_activity_session_metrics_eav.sql b/backend/migrations/054_activity_session_metrics_eav.sql new file mode 100644 index 0000000..18bc421 --- /dev/null +++ b/backend/migrations/054_activity_session_metrics_eav.sql @@ -0,0 +1,80 @@ +-- Migration 054: Activity session metrics (EAV) + attribute profiles +-- Date: 2026-04-14 +-- Additive only: safe for production (no data deletion). +-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md + +-- Session interval (nullable; optional backfill later) +ALTER TABLE activity_log + ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_activity_log_profile_started + ON activity_log (profile_id, started_at DESC) + WHERE started_at IS NOT NULL; + +COMMENT ON COLUMN activity_log.started_at IS 'Training start (wall clock, TZ-aware); optional; for dedupe/analysis'; +COMMENT ON COLUMN activity_log.ended_at IS 'Training end (wall clock, TZ-aware); optional'; + +-- Which parameters apply to which training category (training_types.category) +CREATE TABLE IF NOT EXISTS training_category_parameter ( + id SERIAL PRIMARY KEY, + training_category VARCHAR(50) NOT NULL, + training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE, + sort_order INT NOT NULL DEFAULT 0, + required BOOLEAN NOT NULL DEFAULT false, + ui_group VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_training_category_parameter UNIQUE (training_category, training_parameter_id) +); + +CREATE INDEX IF NOT EXISTS idx_tcp_category ON training_category_parameter (training_category); + +COMMENT ON TABLE training_category_parameter IS 'EAV schema: parameters enabled per training category'; + +-- Per training type: extra parameters or overrides (NULL sort/required/ui = inherit from category row if present) +CREATE TABLE IF NOT EXISTS training_type_parameter ( + id SERIAL PRIMARY KEY, + training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE, + training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE, + sort_order INT, + required BOOLEAN, + ui_group VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_training_type_parameter UNIQUE (training_type_id, training_parameter_id) +); + +CREATE INDEX IF NOT EXISTS idx_ttp_type ON training_type_parameter (training_type_id); + +COMMENT ON TABLE training_type_parameter IS 'EAV schema: add/override parameters for a concrete training_types row'; + +-- EAV values per activity session +CREATE TABLE IF NOT EXISTS activity_session_metrics ( + id BIGSERIAL PRIMARY KEY, + activity_log_id UUID NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE, + training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE RESTRICT, + value_num DOUBLE PRECISION, + value_int BIGINT, + value_text TEXT, + value_bool BOOLEAN, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_activity_session_metric UNIQUE (activity_log_id, training_parameter_id), + CONSTRAINT chk_activity_session_metric_one_value CHECK ( + ( + (value_num IS NOT NULL)::int + + (value_int IS NOT NULL)::int + + (value_text IS NOT NULL)::int + + (value_bool IS NOT NULL)::int + ) = 1 + ) +); + +CREATE INDEX IF NOT EXISTS idx_asm_activity ON activity_session_metrics (activity_log_id); +CREATE INDEX IF NOT EXISTS idx_asm_parameter ON activity_session_metrics (training_parameter_id); + +COMMENT ON TABLE activity_session_metrics IS 'EAV: one row per (session, training_parameter); exactly one value_* set'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 054: activity_session_metrics EAV + attribute profile tables + activity_log timestamps'; +END $$; diff --git a/backend/models.py b/backend/models.py index c2b473a..8be2d09 100644 --- a/backend/models.py +++ b/backend/models.py @@ -3,8 +3,9 @@ Pydantic Models for Mitai Jinkendo API Data validation schemas for request/response bodies. """ -from typing import Optional -from pydantic import BaseModel +from typing import Any, List, Optional + +from pydantic import BaseModel, Field # ── Profile Models ──────────────────────────────────────────────────────────── @@ -91,6 +92,17 @@ class ActivityEntry(BaseModel): training_subcategory: Optional[str] = None # v9d: Denormalized subcategory +class ActivityMetricValue(BaseModel): + parameter_key: str + value: Any + + +class ActivityMetricsReplace(BaseModel): + """Voller Ersatz der EAV-Metriken für eine Session (siehe Agent-Guide).""" + + metrics: List[ActivityMetricValue] = Field(default_factory=list) + + class NutritionDay(BaseModel): date: str kcal: Optional[float] = None diff --git a/backend/placeholder_registrations/activity_session_insights.py b/backend/placeholder_registrations/activity_session_insights.py index bb78f50..faef32a 100644 --- a/backend/placeholder_registrations/activity_session_insights.py +++ b/backend/placeholder_registrations/activity_session_insights.py @@ -130,15 +130,17 @@ def register_activity_session_insights(): key="training_sessions_recent_json", category="Aktivität", description=( - "JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie)" + "JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie, " + "session_id, session_metrics[] aus EAV)" ), resolver_module="backend/placeholder_resolver.py", resolver_function="_safe_json", data_layer_module="backend/data_layer/activity_metrics.py", data_layer_function="get_training_sessions_recent_weeks_data", - source_tables=["activity_log", "training_types"], + source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"], semantic_contract=( - "Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung. " + "Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung; " + "session_metrics[] = Layer-1-EAV-Werte (key, data_type, unit, value) wenn konfiguriert/gespeichert. " "Default 4 ISO-Wochen zurück." ), business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI", @@ -158,7 +160,8 @@ def register_activity_session_insights(): legacy_display="{}", ), known_limitations=( - "Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id." + "Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id. " + "session_metrics nur befüllt, wenn Admin-Profile zugeordnet und Werte in EAV gespeichert sind." ), layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data", layer_2a_decision="_safe_json('training_sessions_recent_json')", diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 40966fc..ec7f014 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage -from models import ActivityEntry +from models import ActivityEntry, ActivityMetricsReplace from routers.profiles import get_pid from feature_logger import log_feature_usage from quality_filter import get_quality_filter_sql @@ -177,6 +177,57 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), return {"ok":True} +@router.put("/{eid}/metrics") +def replace_activity_metrics( + eid: str, + body: ActivityMetricsReplace, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """ + Voller Ersatz der EAV-Session-Metriken (siehe ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md). + """ + from data_layer.activity_session_metrics import ( + ActivitySessionMetricsError, + replace_activity_session_metrics, + ) + + pid = get_pid(x_profile_id) + payload = [m.model_dump() for m in body.metrics] + try: + with get_db() as conn: + cur = get_cursor(conn) + metrics = replace_activity_session_metrics(cur, pid, eid, payload) + conn.commit() + except ActivitySessionMetricsError as err: + raise HTTPException(err.status_code, err.detail) from err + return {"id": eid, "metrics": metrics} + + +@router.get("/{eid}") +def get_activity_session( + eid: str, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1).""" + from data_layer.activity_session_metrics import ( + ActivitySessionMetricsError, + get_activity_session_logical_unit, + ) + from data_layer.utils import serialize_dates + + pid = get_pid(x_profile_id) + try: + with get_db() as conn: + cur = get_cursor(conn) + unit = get_activity_session_logical_unit(cur, pid, eid) + except ActivitySessionMetricsError as err: + raise HTTPException(err.status_code, err.detail) from err + unit["header"] = serialize_dates(unit["header"]) + return unit + + @router.get("/stats") def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Get activity statistics (last 30 entries).""" diff --git a/backend/routers/admin_activity_attribute_profiles.py b/backend/routers/admin_activity_attribute_profiles.py new file mode 100644 index 0000000..d955956 --- /dev/null +++ b/backend/routers/admin_activity_attribute_profiles.py @@ -0,0 +1,184 @@ +""" +Admin: training_category_parameter + training_type_parameter (attribute profiles). + +Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +""" +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from auth import require_admin +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin", tags=["admin", "activity-attribute-profiles"]) + + +class CategoryParameterCreate(BaseModel): + training_category: str = Field(..., min_length=1, max_length=50) + training_parameter_id: int + sort_order: int = 0 + required: bool = False + ui_group: Optional[str] = Field(None, max_length=50) + + +class TypeParameterCreate(BaseModel): + training_type_id: int + training_parameter_id: int + sort_order: Optional[int] = None + required: Optional[bool] = None + ui_group: Optional[str] = Field(None, max_length=50) + + +@router.get("/training-category-parameters") +def admin_list_category_parameters( + category: Optional[str] = Query(None, description="Filter: training_types.category"), + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + if category: + cur.execute( + """ + SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de + FROM training_category_parameter tcp + JOIN training_parameters tp ON tp.id = tcp.training_parameter_id + WHERE tcp.training_category = %s + ORDER BY tcp.sort_order, tp.key + """, + (category.strip(),), + ) + else: + cur.execute( + """ + SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de + FROM training_category_parameter tcp + JOIN training_parameters tp ON tp.id = tcp.training_parameter_id + ORDER BY tcp.training_category, tcp.sort_order, tp.key + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/training-category-parameters") +def admin_add_category_parameter( + body: CategoryParameterCreate, + session: dict = Depends(require_admin), +): + cat = body.training_category.strip() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,)) + if not cur.fetchone(): + raise HTTPException(404, "training_parameter_id unbekannt") + try: + cur.execute( + """ + INSERT INTO training_category_parameter ( + training_category, training_parameter_id, sort_order, required, ui_group + ) VALUES (%s,%s,%s,%s,%s) + RETURNING id + """, + (cat, body.training_parameter_id, body.sort_order, body.required, body.ui_group), + ) + new_id = cur.fetchone()["id"] + conn.commit() + except Exception as e: + conn.rollback() + if "uq_training_category_parameter" in str(e).lower() or "unique" in str(e).lower(): + raise HTTPException(409, "Zuordnung existiert bereits") from e + raise HTTPException(400, str(e)) from e + return {"id": new_id} + + +@router.delete("/training-category-parameters/{link_id}") +def admin_delete_category_parameter( + link_id: int, + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM training_category_parameter WHERE id = %s RETURNING id", + (link_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + conn.commit() + return {"ok": True} + + +@router.get("/training-type-parameters") +def admin_list_type_parameters( + training_type_id: int = Query(..., ge=1), + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT ttp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de + FROM training_type_parameter ttp + JOIN training_parameters tp ON tp.id = ttp.training_parameter_id + WHERE ttp.training_type_id = %s + ORDER BY ttp.sort_order NULLS LAST, tp.key + """, + (training_type_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/training-type-parameters") +def admin_add_type_parameter( + body: TypeParameterCreate, + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM training_types WHERE id = %s", (body.training_type_id,)) + if not cur.fetchone(): + raise HTTPException(404, "training_type_id unbekannt") + cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,)) + if not cur.fetchone(): + raise HTTPException(404, "training_parameter_id unbekannt") + try: + cur.execute( + """ + INSERT INTO training_type_parameter ( + training_type_id, training_parameter_id, sort_order, required, ui_group + ) VALUES (%s,%s,%s,%s,%s) + RETURNING id + """, + ( + body.training_type_id, + body.training_parameter_id, + body.sort_order, + body.required, + body.ui_group, + ), + ) + new_id = cur.fetchone()["id"] + conn.commit() + except Exception as e: + conn.rollback() + if "uq_training_type_parameter" in str(e).lower() or "unique" in str(e).lower(): + raise HTTPException(409, "Zuordnung existiert bereits") from e + raise HTTPException(400, str(e)) from e + return {"id": new_id} + + +@router.delete("/training-type-parameters/{link_id}") +def admin_delete_type_parameter( + link_id: int, + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM training_type_parameter WHERE id = %s RETURNING id", + (link_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/routers/admin_training_parameters.py b/backend/routers/admin_training_parameters.py new file mode 100644 index 0000000..43dbf00 --- /dev/null +++ b/backend/routers/admin_training_parameters.py @@ -0,0 +1,215 @@ +""" +Admin: training_parameters catalog (EAV keys for activity session metrics). + +Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +""" +import re +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from psycopg2 import errors as pg_errors +from psycopg2.extras import Json + +from auth import require_admin +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin/training-parameters", tags=["admin", "training-parameters"]) + +KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$") + +PARAM_CATEGORY = {"physical", "physiological", "subjective", "environmental", "performance"} +DATA_TYPES = {"integer", "float", "string", "boolean"} + + +class TrainingParameterCreate(BaseModel): + key: str = Field(..., min_length=1, max_length=50) + name_de: str = Field(..., min_length=1, max_length=100) + name_en: str = Field(..., min_length=1, max_length=100) + category: str = Field(..., max_length=50) + data_type: str = Field(..., max_length=20) + unit: Optional[str] = Field(None, max_length=20) + description_de: Optional[str] = None + description_en: Optional[str] = None + source_field: Optional[str] = Field(None, max_length=100) + validation_rules: Optional[dict] = None + is_active: bool = True + + +class TrainingParameterUpdate(BaseModel): + name_de: Optional[str] = Field(None, min_length=1, max_length=100) + name_en: Optional[str] = Field(None, min_length=1, max_length=100) + category: Optional[str] = Field(None, max_length=50) + data_type: Optional[str] = Field(None, max_length=20) + unit: Optional[str] = Field(None, max_length=20) + description_de: Optional[str] = None + description_en: Optional[str] = None + source_field: Optional[str] = Field(None, max_length=100) + validation_rules: Optional[dict] = None + is_active: Optional[bool] = None + + +def _norm_key(key: str) -> str: + k = key.strip().lower() + if not KEY_PATTERN.match(k): + raise HTTPException( + 400, + "Ungültiger key: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.", + ) + return k + + +def _validate_category(cat: str) -> str: + c = cat.strip() + if c not in PARAM_CATEGORY: + raise HTTPException(400, f"category muss einer von {sorted(PARAM_CATEGORY)} sein") + return c + + +def _validate_data_type(dt: str) -> str: + d = dt.strip().lower() + if d not in DATA_TYPES: + raise HTTPException(400, f"data_type muss einer von {sorted(DATA_TYPES)} sein") + return d + + +@router.get("") +def admin_list_training_parameters( + include_inactive: bool = Query(False), + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + if include_inactive: + cur.execute( + """ + SELECT * FROM training_parameters + ORDER BY category, key + """ + ) + else: + cur.execute( + """ + SELECT * FROM training_parameters + WHERE is_active = true + ORDER BY category, key + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("") +def admin_create_training_parameter( + body: TrainingParameterCreate, + session: dict = Depends(require_admin), +): + key = _norm_key(body.key) + cat = _validate_category(body.category) + dt = _validate_data_type(body.data_type) + rules = body.validation_rules if body.validation_rules is not None else {} + + with get_db() as conn: + cur = get_cursor(conn) + try: + cur.execute( + """ + INSERT INTO training_parameters ( + key, name_de, name_en, category, data_type, unit, + description_de, description_en, source_field, validation_rules, is_active + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id + """, + ( + key, + body.name_de.strip(), + body.name_en.strip(), + cat, + dt, + body.unit.strip() if body.unit else None, + body.description_de, + body.description_en, + body.source_field.strip() if body.source_field else None, + Json(rules), + body.is_active, + ), + ) + new_id = cur.fetchone()["id"] + conn.commit() + except pg_errors.UniqueViolation: + conn.rollback() + raise HTTPException(409, "Parameter-key existiert bereits") from None + return {"id": new_id, "key": key} + + +@router.put("/{param_id}") +def admin_update_training_parameter( + param_id: int, + body: TrainingParameterUpdate, + session: dict = Depends(require_admin), +): + cols: list[str] = [] + vals: list[Any] = [] + + if body.name_de is not None: + cols.append("name_de = %s") + vals.append(body.name_de.strip()) + if body.name_en is not None: + cols.append("name_en = %s") + vals.append(body.name_en.strip()) + if body.category is not None: + cols.append("category = %s") + vals.append(_validate_category(body.category)) + if body.data_type is not None: + cols.append("data_type = %s") + vals.append(_validate_data_type(body.data_type)) + if body.unit is not None: + cols.append("unit = %s") + vals.append(body.unit.strip() or None) + if body.description_de is not None: + cols.append("description_de = %s") + vals.append(body.description_de) + if body.description_en is not None: + cols.append("description_en = %s") + vals.append(body.description_en) + if body.source_field is not None: + cols.append("source_field = %s") + vals.append(body.source_field.strip() or None) + if body.validation_rules is not None: + cols.append("validation_rules = %s") + vals.append(Json(body.validation_rules)) + if body.is_active is not None: + cols.append("is_active = %s") + vals.append(body.is_active) + + if not cols: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + + vals.append(param_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f"UPDATE training_parameters SET {', '.join(cols)} WHERE id = %s RETURNING id", + vals, + ) + if not cur.fetchone(): + raise HTTPException(404, "Parameter nicht gefunden") + conn.commit() + return {"ok": True, "id": param_id} + + +@router.delete("/{param_id}") +def admin_deactivate_training_parameter( + param_id: int, + session: dict = Depends(require_admin), +): + """Soft-delete: is_active = false (FK von session_metrics verhindert hartes Löschen).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE training_parameters SET is_active = false WHERE id = %s RETURNING id", + (param_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Parameter nicht gefunden") + conn.commit() + return {"ok": True, "id": param_id} diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py new file mode 100644 index 0000000..930ec21 --- /dev/null +++ b/backend/tests/test_activity_session_metrics.py @@ -0,0 +1,204 @@ +"""Unit tests for data_layer.activity_session_metrics (no DB for most cases).""" + +import uuid + +import pytest + +from data_layer.activity_session_metrics import ( + ActivitySessionMetricsError, + enrich_sessions_with_metrics, + merge_parameter_schema_rows, + resolve_activity_attribute_schema, + _row_value_tuple, + _validate_single_value, +) + + +def _tp_row( + pid: int, + key: str, + *, + data_type: str = "integer", + cat_sort: int = 0, + cat_required: bool = False, + name_de: str = "X", + name_en: str = "X", + param_category: str = "physical", + unit: str | None = None, + validation_rules: dict | None = None, + source_field: str | None = None, +): + return { + "training_parameter_id": pid, + "cat_sort": cat_sort, + "cat_required": cat_required, + "cat_ui_group": None, + "key": key, + "name_de": name_de, + "name_en": name_en, + "param_category": param_category, + "data_type": data_type, + "unit": unit, + "validation_rules": validation_rules or {}, + "source_field": source_field, + } + + +def _ttp_row( + pid: int, + key: str, + *, + typ_sort: int | None = None, + typ_required: bool | None = None, + typ_ui_group: str | None = None, + data_type: str = "integer", + name_de: str = "X", + name_en: str = "X", + param_category: str = "physical", + unit: str | None = None, + validation_rules: dict | None = None, + source_field: str | None = None, +): + return { + "training_parameter_id": pid, + "typ_sort": typ_sort, + "typ_required": typ_required, + "typ_ui_group": typ_ui_group, + "key": key, + "name_de": name_de, + "name_en": name_en, + "param_category": param_category, + "data_type": data_type, + "unit": unit, + "validation_rules": validation_rules or {}, + "source_field": source_field, + } + + +def test_merge_category_only_sorted_by_sort_order_then_key(): + cat = [ + _tp_row(2, "zebra", cat_sort=10), + _tp_row(1, "alpha", cat_sort=5), + ] + merged = merge_parameter_schema_rows(cat, []) + assert [m["key"] for m in merged] == ["alpha", "zebra"] + assert merged[0]["required"] is False + + +def test_merge_type_overrides_required_and_sort(): + cat = [_tp_row(1, "rpe", cat_sort=0, cat_required=False)] + typ = [_ttp_row(1, "rpe", typ_sort=99, typ_required=True)] + merged = merge_parameter_schema_rows(cat, typ) + assert len(merged) == 1 + assert merged[0]["sort_order"] == 99 + assert merged[0]["required"] is True + + +def test_merge_type_adds_parameter_not_in_category(): + typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")] + merged = merge_parameter_schema_rows([], typ) + assert len(merged) == 1 + assert merged[0]["key"] == "cadence" + assert merged[0]["required"] is True + + +def test_validate_integer_range(): + _validate_single_value("integer", 5, {"min": 1, "max": 10}) + with pytest.raises(ActivitySessionMetricsError) as ei: + _validate_single_value("integer", 0, {"min": 1}) + assert ei.value.status_code == 400 + + +def test_validate_float_accepts_int(): + _validate_single_value("float", 3, {"min": 0, "max": 10}) + + +def test_validate_boolean_rejects_int(): + with pytest.raises(ActivitySessionMetricsError): + _validate_single_value("boolean", 1, {}) + + +def test_row_value_tuple_mapping(): + assert _row_value_tuple("integer", 42) == (None, 42, None, None) + assert _row_value_tuple("float", 1.5) == (1.5, None, None, None) + assert _row_value_tuple("string", "hi") == (None, None, "hi", None) + assert _row_value_tuple("boolean", True) == (None, None, None, True) + + +class _FakeCursor: + """Sequences fetchone/fetchall for resolve_activity_attribute_schema.""" + + def __init__(self, fetchone_chain, fetchall_chain): + self._fetchone = list(fetchone_chain) + self._fetchall = list(fetchall_chain) + self.executes: list[tuple] = [] + + def execute(self, sql, params=None): + self.executes.append((sql, params)) + + def fetchone(self): + return self._fetchone.pop(0) + + def fetchall(self): + return self._fetchall.pop(0) + + +def test_resolve_with_explicit_category_no_type(): + cur = _FakeCursor( + fetchone_chain=[], + fetchall_chain=[ + [ + _tp_row(1, "rpe", cat_sort=0), + ], + ], + ) + out = resolve_activity_attribute_schema(cur, "cardio", None) + assert len(out) == 1 + assert out[0]["key"] == "rpe" + assert len(cur.executes) == 1 + + +def test_resolve_loads_category_from_training_type_id(): + cur = _FakeCursor( + fetchone_chain=[{"category": "strength"}], + fetchall_chain=[ + [_tp_row(1, "rpe", cat_sort=0)], + [], + ], + ) + out = resolve_activity_attribute_schema(cur, None, 42) + assert len(out) == 1 + assert cur.executes[0][1] == (42,) + + +def test_enrich_sessions_batch(): + aid = str(uuid.uuid4()) + bid = str(uuid.uuid4()) + + class _Cur: + def __init__(self): + self.params = None + + def execute(self, sql, params=None): + self.sql = sql + self.params = params + + def fetchall(self): + return [ + { + "activity_log_id": uuid.UUID(aid), + "key": "rpe", + "data_type": "integer", + "unit": None, + "value_num": None, + "value_int": 7, + "value_text": None, + "value_bool": None, + }, + ] + + sessions = [{"id": aid}, {"id": bid}] + enrich_sessions_with_metrics(_Cur(), sessions) + assert sessions[0]["session_metrics"][0]["value"] == 7 + assert sessions[0]["session_metrics"][0]["key"] == "rpe" + assert sessions[1]["session_metrics"] == []