feat: Add Activity Session Metrics functionality
- Introduced Activity Session Metrics for enhanced tracking of session data. - Updated backend to support new API endpoints for managing session metrics. - Added new Pydantic models for activity metrics and replaced metrics functionality. - Enhanced data layer to include session metrics in recent training session data. - Updated documentation to reflect changes in session metrics handling.
This commit is contained in:
parent
1b01f5e6d0
commit
48508c164e
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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", []),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
364
backend/data_layer/activity_session_metrics.py
Normal file
364
backend/data_layer/activity_session_metrics.py
Normal file
|
|
@ -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, [])
|
||||
|
|
@ -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("/")
|
||||
|
|
|
|||
80
backend/migrations/054_activity_session_metrics_eav.sql
Normal file
80
backend/migrations/054_activity_session_metrics_eav.sql
Normal file
|
|
@ -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 $$;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')",
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
184
backend/routers/admin_activity_attribute_profiles.py
Normal file
184
backend/routers/admin_activity_attribute_profiles.py
Normal file
|
|
@ -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}
|
||||
215
backend/routers/admin_training_parameters.py
Normal file
215
backend/routers/admin_training_parameters.py
Normal file
|
|
@ -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}
|
||||
204
backend/tests/test_activity_session_metrics.py
Normal file
204
backend/tests/test_activity_session_metrics.py
Normal file
|
|
@ -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"] == []
|
||||
Loading…
Reference in New Issue
Block a user