feat: Add Activity Session Metrics functionality
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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:
Lars 2026-04-14 11:49:14 +02:00
parent 1b01f5e6d0
commit 48508c164e
13 changed files with 1257 additions and 7 deletions

View File

@ -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) |
---

View File

@ -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.

View File

@ -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 **MifflinSt 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).

View File

@ -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", []),
}
)

View 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, [])

View File

@ -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("/")

View 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 $$;

View File

@ -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

View File

@ -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')",

View File

@ -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)."""

View 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}

View 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}

View 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"] == []