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_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
||||||
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||||
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
| `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) |
|
| `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`** |
|
> | **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`** |
|
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||||
|
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
|
||||||
|
|
||||||
## Claude Code Verantwortlichkeiten
|
## 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
|
- **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.
|
- **`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)
|
### 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).
|
- **`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
|
from datetime import datetime, timedelta, date, time
|
||||||
import statistics
|
import statistics
|
||||||
from db import get_db, get_cursor, r2d
|
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
|
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),
|
(profile_id, cutoff),
|
||||||
)
|
)
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
|
enrich_sessions_with_metrics(cur, rows)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return {
|
return {
|
||||||
|
|
@ -1141,6 +1143,7 @@ def get_training_sessions_recent_weeks_data(
|
||||||
hr_m = r.get("hr_max")
|
hr_m = r.get("hr_max")
|
||||||
by_week[wk].append(
|
by_week[wk].append(
|
||||||
{
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
"date": d,
|
"date": d,
|
||||||
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
|
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
|
||||||
"activity_type": r.get("activity_type"),
|
"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_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,
|
"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,
|
"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 admin_reference_value_types # Admin: Referenzwert-Typen
|
||||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
|
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 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 ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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(app_dashboard.router) # /api/app/dashboard-layout
|
||||||
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
|
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_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 ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@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.
|
Data validation schemas for request/response bodies.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Any, List, Optional
|
||||||
from pydantic import BaseModel
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# ── Profile Models ────────────────────────────────────────────────────────────
|
# ── Profile Models ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -91,6 +92,17 @@ class ActivityEntry(BaseModel):
|
||||||
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
|
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):
|
class NutritionDay(BaseModel):
|
||||||
date: str
|
date: str
|
||||||
kcal: Optional[float] = None
|
kcal: Optional[float] = None
|
||||||
|
|
|
||||||
|
|
@ -130,15 +130,17 @@ def register_activity_session_insights():
|
||||||
key="training_sessions_recent_json",
|
key="training_sessions_recent_json",
|
||||||
category="Aktivität",
|
category="Aktivität",
|
||||||
description=(
|
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_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="_safe_json",
|
resolver_function="_safe_json",
|
||||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||||
data_layer_function="get_training_sessions_recent_weeks_data",
|
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=(
|
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."
|
"Default 4 ISO-Wochen zurück."
|
||||||
),
|
),
|
||||||
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
|
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
|
||||||
|
|
@ -159,6 +161,7 @@ def register_activity_session_insights():
|
||||||
),
|
),
|
||||||
known_limitations=(
|
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_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
|
||||||
layer_2a_decision="_safe_json('training_sessions_recent_json')",
|
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 db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
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 routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
from quality_filter import get_quality_filter_sql
|
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}
|
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")
|
@router.get("/stats")
|
||||||
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Get activity statistics (last 30 entries)."""
|
"""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