Erste Version - Universal CSV Importer für EAV und activity_log #85
|
|
@ -113,6 +113,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
||||
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
||||
| `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` | Session-Metriken EAV, Attributprofile, Layer-1, Prod-Migration |
|
||||
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
# Activity Session Metrics (EAV) – Umsetzungs- & Agent-Guide
|
||||
|
||||
**Stand:** 2026-04-14
|
||||
**Status:** Kern-Backend (Migration 054, Layer 1, Admin- & Nutzer-API) umgesetzt; Admin-UI & CSV-Mapping folgen.
|
||||
**Ziel:** Sportspezifische **Attributprofile** (Kategorie + optional Trainingstyp-Override) administrierbar; Messwerte pro Session in **EAV**; **alle Auswertungen** sollen künftig über **Layer 1** (`data_layer`) laufen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Produktions-Migrationen (Pflicht)
|
||||
|
||||
- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story.
|
||||
- Neue Migrationen: **`backend/migrations/054_*.sql`** (nächste freie Nummer nach 053 einhalten).
|
||||
- **Prod-Checkliste vor Deploy:**
|
||||
1. Backup / Snapshot der DB.
|
||||
2. Migration auf **Kopie** der Prod-DB laufen lassen; Container-Start (`db_init`) verifizieren.
|
||||
3. Stichprobe: `activity_log`-Zeilen unverändert; neue Tabellen leer oder nur Seed.
|
||||
- **Datenhaltung:** Bestehende Spalten in `activity_log` bleiben **Quelle für Alt-Daten**; EAV (`activity_session_metrics`) ist der **kanonische Ort für konfigurierte Session-Metriken**, sobald geschrieben. Backfill Altspalten → EAV ist **separater Schritt** (siehe §6).
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenmodell (Ist nach Migration 054)
|
||||
|
||||
| Tabelle | Zweck |
|
||||
|---------|--------|
|
||||
| `training_parameters` | Katalog messbarer Größen (`key`, `data_type`, `unit`, `validation_rules`, …) – bereits Migration 013; Admin-API ergänzt. |
|
||||
| `training_category_parameter` | Welche Parameter für welche **`training_types.category`** (z. B. `cardio`) gelten: `sort_order`, `required`, `ui_group`. |
|
||||
| `training_type_parameter` | Zusatzparameter oder **Overrides** pro **`training_types.id`**: `sort_order`, `required`, `ui_group` (NULL = von Kategorie erben). |
|
||||
| `activity_session_metrics` | EAV: `(activity_log_id, training_parameter_id)` eindeutig; genau eine Wertspalte `value_num` / `value_int` / `value_text` / `value_bool`. |
|
||||
| `activity_log` | **Neu:** `started_at`, `ended_at` (`TIMESTAMPTZ`, nullable) – für spätere Dedupe/Intervalle; **kein** Pflichtfeld in v1. |
|
||||
|
||||
**Merge-Logik effektives Schema** (Layer 1, eine Funktion):
|
||||
|
||||
1. Kategorie ermitteln: aus Zeile `training_category` oder aus `training_types.category` via `training_type_id`.
|
||||
2. Basis = alle Zeilen `training_category_parameter` für diese Kategorie, Join auf `training_parameters` (aktiv).
|
||||
3. Für jeden Eintrag in `training_type_parameter` zum gewählten Typ: gleiche `training_parameter_id` → Overrides anwenden; nur im Typ vorhanden → anhängen.
|
||||
4. Sortierung: `sort_order` aufsteigend, dann `key`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layer 1 – Kanonische Module
|
||||
|
||||
| Modul | Pfad | Aufgabe |
|
||||
|-------|------|---------|
|
||||
| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`. |
|
||||
|
||||
**Regeln für Agenten:**
|
||||
|
||||
- **Keine** zweite Implementierung derselben Merge- oder Validierungslogik in Routern.
|
||||
- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`).
|
||||
- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`.
|
||||
|
||||
---
|
||||
|
||||
## 4. API (Ist / geplant)
|
||||
|
||||
### Admin (`require_admin`)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET/POST | `/api/admin/training-parameters` | Katalog lesen / Parameter anlegen |
|
||||
| PUT/DELETE | `/api/admin/training-parameters/{id}` | Aktualisieren / Soft-deaktivieren (`is_active`) |
|
||||
| GET | `/api/admin/training-category-parameters?category=` | Zuordnungen Kategorie |
|
||||
| POST | `/api/admin/training-category-parameters` | Zuordnung anlegen |
|
||||
| DELETE | `/api/admin/training-category-parameters/{id}` | Zuordnung entfernen |
|
||||
| GET | `/api/admin/training-type-parameters?training_type_id=` | Zuordnungen Typ |
|
||||
| POST | `/api/admin/training-type-parameters` | Zuordnung anlegen |
|
||||
| DELETE | `/api/admin/training-type-parameters/{id}` | Zuordnung entfernen |
|
||||
|
||||
Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_activity_attribute_profiles.py`.
|
||||
|
||||
### Nutzer (`require_auth`)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET | `/api/activity/{eid}` | Session-Kopf + `schema` + `metrics` (Layer 1) |
|
||||
| PUT | `/api/activity/{eid}/metrics` | **Voller Ersatz** der EAV-Metriken für diese Session (Liste `{parameter_key, value}`) |
|
||||
|
||||
`ActivityEntry` unverändert für bestehende Create/Update-Routen; optionale Erweiterung um `started_at`/`ended_at` in späterem Schritt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Agent-Checkliste (nächste Iterationen)
|
||||
|
||||
- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“.
|
||||
- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`.
|
||||
- [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor).
|
||||
- [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`.
|
||||
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
|
||||
|
||||
---
|
||||
|
||||
## 6. Backfill (nicht in Migration 054)
|
||||
|
||||
Separates Skript oder Migration **055+**, wenn fachlich freigegeben:
|
||||
|
||||
- Pro aktivem `training_parameter` mit gesetztem `source_field`: Wert aus `activity_log` lesen, in EAV schreiben, wenn noch keine Zeile existiert.
|
||||
- Idempotent (`ON CONFLICT DO NOTHING` oder Upsert-Regel dokumentieren).
|
||||
|
||||
---
|
||||
|
||||
## 7. Automatische Tests (pytest, ohne DB)
|
||||
|
||||
Aus **`backend/`**:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_activity_session_metrics.py -v
|
||||
```
|
||||
|
||||
Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_validate_single_value`), `resolve_activity_attribute_schema` mit Mock-Cursor, `enrich_sessions_with_metrics` mit Mock-Cursor.
|
||||
|
||||
---
|
||||
|
||||
## 8. Referenzen
|
||||
|
||||
- Migration 013: `training_parameters`
|
||||
- Migration 004/014: `training_types`, `activity_log`-Erweiterungen
|
||||
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
|
||||
- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data`
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1 · Bei Schema- oder API-Änderungen dieses Dokument und ggf. `CLAUDE.md` Kurzverweis aktualisieren.
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
|
||||
|
||||
## Claude Code Verantwortlichkeiten
|
||||
|
||||
|
|
@ -115,6 +116,13 @@ 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).
|
||||
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
|
||||
|
||||
### 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).
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
|
|||
return lines
|
||||
|
||||
|
||||
def canonical_csv_header_label(name: str | None) -> str:
|
||||
"""
|
||||
Einheitlicher Spalten-Key für Analyse (Vorlage/Dialog), Import und Signatur.
|
||||
BOM und NBSP (häufig in Excel/Apple-Exporten) werden vereinheitlicht, damit
|
||||
field_mappings exakt zu DictReader-Zeilen passt.
|
||||
"""
|
||||
if name is None:
|
||||
return ""
|
||||
s = str(name).replace("\ufeff", "").replace("\u00a0", " ").strip()
|
||||
return s
|
||||
|
||||
|
||||
def parse_csv_sample(
|
||||
text: str,
|
||||
delimiter: str | None = None,
|
||||
|
|
@ -85,7 +97,7 @@ def parse_csv_sample(
|
|||
return [], [], delim
|
||||
|
||||
if has_header:
|
||||
headers = [h.strip() for h in rows_raw[0]]
|
||||
headers = [canonical_csv_header_label(h) for h in rows_raw[0]]
|
||||
data = rows_raw[1 : 1 + max_data_rows]
|
||||
else:
|
||||
n = len(rows_raw[0])
|
||||
|
|
@ -103,7 +115,7 @@ def parse_csv_sample(
|
|||
|
||||
|
||||
def normalize_header_for_signature(name: str) -> str:
|
||||
s = name.strip().lower()
|
||||
s = canonical_csv_header_label(name).lower()
|
||||
s = re.sub(r"\s+", "_", s)
|
||||
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
|
||||
return s.strip("_")
|
||||
|
|
@ -111,7 +123,9 @@ def normalize_header_for_signature(name: str) -> str:
|
|||
|
||||
def column_signature(headers: List[str]) -> List[str]:
|
||||
"""Sortierte normalisierte Spaltennamen für Signatur-Vergleich."""
|
||||
return sorted({normalize_header_for_signature(h) for h in headers if h is not None and str(h).strip()})
|
||||
return sorted(
|
||||
{normalize_header_for_signature(h) for h in headers if h is not None and canonical_csv_header_label(str(h))}
|
||||
)
|
||||
|
||||
|
||||
def headers_signature_match_score(sig_csv: List[str], sig_template: List[str]) -> float:
|
||||
|
|
@ -178,12 +192,6 @@ def get_csv_import_limits(conn_row: dict | None) -> dict[str, int]:
|
|||
return defaults
|
||||
|
||||
|
||||
def _strip_header_key(k: str | None) -> str:
|
||||
if k is None:
|
||||
return ""
|
||||
return str(k).strip().removeprefix("\ufeff")
|
||||
|
||||
|
||||
def iter_csv_dict_rows(
|
||||
text: str,
|
||||
delimiter: str,
|
||||
|
|
@ -205,4 +213,8 @@ def iter_csv_dict_rows(
|
|||
continue
|
||||
if not any(v and str(v).strip() for v in row.values()):
|
||||
continue
|
||||
yield {_strip_header_key(k): (v or "").strip() for k, v in row.items() if _strip_header_key(k)}
|
||||
yield {
|
||||
canonical_csv_header_label(k): (v or "").strip()
|
||||
for k, v in row.items()
|
||||
if canonical_csv_header_label(k)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,6 @@ from csv_parser.type_converter import build_row_after_mapping
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
|
||||
|
||||
_EVALUATION_AVAILABLE = True
|
||||
except Exception: # pragma: no cover
|
||||
_evaluate_and_save_activity = None
|
||||
_EVALUATION_AVAILABLE = False
|
||||
|
||||
|
||||
def _resolve_training_type_for_activity(cur, activity_type: str, profile_id: str):
|
||||
"""Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock)."""
|
||||
|
|
@ -814,6 +806,17 @@ def _import_activity(
|
|||
error_details: list,
|
||||
affected_ids: dict,
|
||||
) -> dict[str, int]:
|
||||
from data_layer.activity_time_normalize import normalize_activity_start
|
||||
from data_layer.activity_persistence_orchestrator import (
|
||||
activity_csv_registry_updates_from_mapped,
|
||||
find_activity_duplicate_id,
|
||||
insert_activity_csv_minimal,
|
||||
new_activity_id,
|
||||
run_activity_post_write_hooks_import,
|
||||
update_activity_columns,
|
||||
)
|
||||
from data_layer.activity_session_metrics import upsert_session_metrics_from_csv_mapped
|
||||
|
||||
rows_total = 0
|
||||
inserted = 0
|
||||
updated = 0
|
||||
|
|
@ -885,6 +888,7 @@ def _import_activity(
|
|||
|
||||
wtype = str(activity_type).strip()
|
||||
iso = date_d.isoformat()
|
||||
_, workout_start_t = normalize_activity_start(start_key)
|
||||
|
||||
# Pro Zeile: bei SQL-Fehler sonst „current transaction is aborted“ bis Xact-Ende.
|
||||
cur.execute("SAVEPOINT csv_activity_row")
|
||||
|
|
@ -892,113 +896,79 @@ def _import_activity(
|
|||
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
|
||||
cur, wtype, profile_id
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM activity_log
|
||||
WHERE profile_id = %s AND date = %s AND start_time = %s
|
||||
""",
|
||||
(profile_id, iso, start_key),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
registry_updates = activity_csv_registry_updates_from_mapped(mapped)
|
||||
existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t)
|
||||
|
||||
if existing:
|
||||
eid = existing["id"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE activity_log
|
||||
SET end_time = %s,
|
||||
activity_type = %s,
|
||||
duration_min = %s,
|
||||
kcal_active = %s,
|
||||
kcal_resting = %s,
|
||||
hr_avg = %s,
|
||||
hr_max = %s,
|
||||
distance_km = %s,
|
||||
training_type_id = %s,
|
||||
training_category = %s,
|
||||
training_subcategory = %s,
|
||||
source = 'csv'
|
||||
WHERE id = %s
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
end_str or None,
|
||||
wtype,
|
||||
duration_min,
|
||||
kcal_a,
|
||||
kcal_r,
|
||||
hr_a,
|
||||
hr_m,
|
||||
dist,
|
||||
training_type_id,
|
||||
training_category,
|
||||
training_subcategory,
|
||||
eid,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
updated += 1
|
||||
if row and row.get("id"):
|
||||
affected_ids["activity_log"].append(str(row["id"]))
|
||||
aid = eid
|
||||
else:
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO activity_log (
|
||||
id, profile_id, date, start_time, end_time, activity_type, duration_min,
|
||||
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
|
||||
source, training_type_id, training_category, training_subcategory, created
|
||||
)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s,%s,%s,CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
eid,
|
||||
profile_id,
|
||||
iso,
|
||||
start_key,
|
||||
end_str or None,
|
||||
wtype,
|
||||
duration_min,
|
||||
kcal_a,
|
||||
kcal_r,
|
||||
hr_a,
|
||||
hr_m,
|
||||
dist,
|
||||
training_type_id,
|
||||
training_category,
|
||||
training_subcategory,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
inserted += 1
|
||||
new_entries += 1
|
||||
if row and row.get("id"):
|
||||
affected_ids["activity_log"].append(str(row["id"]))
|
||||
aid = eid
|
||||
|
||||
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
||||
try:
|
||||
activity_dict = {
|
||||
"id": aid,
|
||||
"profile_id": profile_id,
|
||||
"date": iso,
|
||||
"training_type_id": training_type_id,
|
||||
if existing_id:
|
||||
upd = {
|
||||
"start_time": workout_start_t,
|
||||
"end_time": end_str or None,
|
||||
"activity_type": wtype,
|
||||
"duration_min": duration_min,
|
||||
"kcal_active": kcal_a,
|
||||
"kcal_resting": kcal_r,
|
||||
"hr_avg": hr_a,
|
||||
"hr_max": hr_m,
|
||||
"distance_km": dist,
|
||||
"kcal_active": kcal_a,
|
||||
"kcal_resting": kcal_r,
|
||||
"rpe": None,
|
||||
"pace_min_per_km": None,
|
||||
"cadence": None,
|
||||
"elevation_gain": None,
|
||||
"training_type_id": training_type_id,
|
||||
"training_category": training_category,
|
||||
"training_subcategory": training_subcategory,
|
||||
"source": "csv",
|
||||
}
|
||||
_evaluate_and_save_activity(cur, aid, activity_dict, training_type_id, profile_id)
|
||||
except Exception as eval_err:
|
||||
logger.warning("[csv activity] Auto-Eval fehlgeschlagen: %s", eval_err)
|
||||
upd.update(registry_updates)
|
||||
update_activity_columns(cur, profile_id, existing_id, upd)
|
||||
updated += 1
|
||||
affected_ids["activity_log"].append(str(existing_id))
|
||||
aid = existing_id
|
||||
else:
|
||||
eid = new_activity_id()
|
||||
insert_activity_csv_minimal(
|
||||
cur,
|
||||
profile_id,
|
||||
eid,
|
||||
date_iso=iso,
|
||||
start_time=workout_start_t,
|
||||
end_time=end_str or None,
|
||||
activity_type=wtype,
|
||||
duration_min=duration_min,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
hr_avg=hr_a,
|
||||
hr_max=hr_m,
|
||||
distance_km=dist,
|
||||
training_type_id=training_type_id,
|
||||
training_category=training_category,
|
||||
training_subcategory=training_subcategory,
|
||||
source="csv",
|
||||
)
|
||||
inserted += 1
|
||||
new_entries += 1
|
||||
affected_ids["activity_log"].append(str(eid))
|
||||
aid = eid
|
||||
if registry_updates:
|
||||
update_activity_columns(cur, profile_id, aid, registry_updates)
|
||||
|
||||
run_activity_post_write_hooks_import(
|
||||
cur,
|
||||
profile_id,
|
||||
str(aid),
|
||||
workout_date=iso,
|
||||
training_type_id=training_type_id,
|
||||
duration_min=duration_min,
|
||||
hr_avg=hr_a,
|
||||
hr_max=hr_m,
|
||||
distance_km=dist,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
)
|
||||
upsert_session_metrics_from_csv_mapped(
|
||||
cur,
|
||||
profile_id,
|
||||
str(aid),
|
||||
mapped,
|
||||
training_category,
|
||||
training_type_id,
|
||||
)
|
||||
cur.execute("RELEASE SAVEPOINT csv_activity_row")
|
||||
except Exception as e:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -37,12 +37,16 @@ def validate_import_row_processing(
|
|||
module: str,
|
||||
spec: Mapping[str, Any],
|
||||
field_mappings: Mapping[str, Any],
|
||||
cur=None,
|
||||
) -> None:
|
||||
"""Wirft ValueError bei ungültiger Konfiguration."""
|
||||
mod = get_module_definition(module)
|
||||
if not mod:
|
||||
raise ValueError(f"Unbekanntes Modul: {module}")
|
||||
allowed = set(mod.get("fields") or [])
|
||||
if module == "activity" and cur is not None:
|
||||
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
|
||||
allowed.update(str(r["key"]) for r in cur.fetchall())
|
||||
fm_targets = {str(v) for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
||||
|
||||
group_by = spec.get("group_by") or []
|
||||
|
|
|
|||
|
|
@ -127,13 +127,19 @@ def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | No
|
|||
return None
|
||||
|
||||
|
||||
def _alias_suggest(norm: str, module: str, used: set[str]) -> str | None:
|
||||
def _alias_suggest(
|
||||
norm: str,
|
||||
module: str,
|
||||
used: set[str],
|
||||
*,
|
||||
field_order: list[str] | None = None,
|
||||
) -> str | None:
|
||||
aliases = _MODULE_HEADER_ALIASES.get(module, {})
|
||||
mod = get_module_definition(module)
|
||||
if not mod:
|
||||
return None
|
||||
field_order = list(mod["fields"].keys())
|
||||
for db_field in field_order:
|
||||
order = field_order if field_order is not None else list(mod["fields"].keys())
|
||||
for db_field in order:
|
||||
if db_field in used:
|
||||
continue
|
||||
tokens = aliases.get(db_field, frozenset())
|
||||
|
|
@ -152,6 +158,8 @@ def suggest_field_mappings(
|
|||
headers: list[str],
|
||||
module: str,
|
||||
seed_fm: Mapping[str, str] | None = None,
|
||||
*,
|
||||
effective_fields: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
|
||||
|
|
@ -164,13 +172,16 @@ def suggest_field_mappings(
|
|||
if not mod:
|
||||
return {h: "-" for h in headers}
|
||||
|
||||
fields_map = dict(effective_fields) if effective_fields is not None else dict(mod["fields"])
|
||||
field_order = list(fields_map.keys())
|
||||
|
||||
fm: dict[str, str] = {h: "-" for h in headers}
|
||||
used: set[str] = set()
|
||||
|
||||
if seed_fm:
|
||||
for h in headers:
|
||||
db = _match_seed_to_db_field(h, seed_fm)
|
||||
if db and db not in used:
|
||||
if db and db not in used and db in fields_map:
|
||||
fm[h] = db
|
||||
used.add(db)
|
||||
|
||||
|
|
@ -178,7 +189,7 @@ def suggest_field_mappings(
|
|||
if fm[h] != "-":
|
||||
continue
|
||||
norm = _norm_key(h)
|
||||
db = _alias_suggest(norm, module, used)
|
||||
db = _alias_suggest(norm, module, used, field_order=field_order)
|
||||
if db:
|
||||
fm[h] = db
|
||||
used.add(db)
|
||||
|
|
@ -190,6 +201,8 @@ def build_type_conversions_for_mapping(
|
|||
module: str,
|
||||
field_mappings: Mapping[str, str],
|
||||
seed_tc: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
effective_fields: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
|
||||
if module == "sleep":
|
||||
|
|
@ -198,6 +211,7 @@ def build_type_conversions_for_mapping(
|
|||
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
|
||||
out: dict[str, Any] = {}
|
||||
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
||||
field_meta = dict(effective_fields) if effective_fields is not None else None
|
||||
|
||||
if seed_tc:
|
||||
for k, v in seed_tc.items():
|
||||
|
|
@ -208,6 +222,20 @@ def build_type_conversions_for_mapping(
|
|||
if t not in out and t in defaults:
|
||||
out[t] = deepcopy(defaults[t])
|
||||
|
||||
for t in sorted(targets):
|
||||
if t in out:
|
||||
continue
|
||||
finfo = (field_meta or {}).get(t) if field_meta else None
|
||||
if not finfo:
|
||||
continue
|
||||
typ = finfo.get("type")
|
||||
if typ == "int":
|
||||
out[t] = {"type": "int", "flexible": True}
|
||||
elif typ == "float":
|
||||
out[t] = {"type": "float", "decimal_separator": "auto", "flexible": True}
|
||||
else:
|
||||
out[t] = {"type": "string"}
|
||||
|
||||
_apply_energy_kj_hint_from_headers(module, field_mappings, out)
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -37,16 +37,43 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
"activity": {
|
||||
"table": "activity_log",
|
||||
"fields": {
|
||||
"date": {"type": "date", "required": False},
|
||||
"start_time": {"type": "datetime", "required": False},
|
||||
"end_time": {"type": "datetime", "required": False},
|
||||
"activity_type": {"type": "string", "required": True},
|
||||
"duration_min": {"type": "float", "required": False, "min": 0},
|
||||
"kcal_active": {"type": "float", "required": False, "unit": "kcal"},
|
||||
"kcal_resting": {"type": "float", "required": False, "unit": "kcal"},
|
||||
"distance_km": {"type": "float", "required": False, "unit": "km"},
|
||||
"hr_avg": {"type": "float", "required": False, "min": 30, "max": 220},
|
||||
"hr_max": {"type": "float", "required": False, "min": 30, "max": 220},
|
||||
"date": {"type": "date", "required": False, "label_de": "Datum"},
|
||||
"start_time": {
|
||||
"type": "datetime",
|
||||
"required": False,
|
||||
"label_de": "Start (Datum/Uhrzeit)",
|
||||
},
|
||||
"end_time": {"type": "datetime", "required": False, "label_de": "Ende (Datum/Uhrzeit)"},
|
||||
"activity_type": {"type": "string", "required": True, "label_de": "Trainingsart / Workout-Typ"},
|
||||
"duration_min": {"type": "float", "required": False, "min": 0, "label_de": "Dauer (Minuten)"},
|
||||
"kcal_active": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien aktiv"},
|
||||
"kcal_resting": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien Ruhe"},
|
||||
"distance_km": {"type": "float", "required": False, "unit": "km", "label_de": "Distanz (km)"},
|
||||
"hr_avg": {
|
||||
"type": "float",
|
||||
"required": False,
|
||||
"min": 30,
|
||||
"max": 220,
|
||||
"label_de": "Herzfrequenz Ø (bpm)",
|
||||
},
|
||||
"hr_max": {
|
||||
"type": "float",
|
||||
"required": False,
|
||||
"min": 30,
|
||||
"max": 220,
|
||||
"label_de": "Herzfrequenz max (bpm)",
|
||||
},
|
||||
"hr_min": {"type": "int", "required": False, "label_de": "Herzfrequenz min (bpm)"},
|
||||
"rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"},
|
||||
"pace_min_per_km": {"type": "float", "required": False, "label_de": "Tempo (min/km)"},
|
||||
"cadence": {"type": "int", "required": False, "label_de": "Kadenz"},
|
||||
"avg_power": {"type": "int", "required": False, "label_de": "Leistung Ø (W)"},
|
||||
"elevation_gain": {"type": "int", "required": False, "label_de": "Höhenmeter / Aufstieg"},
|
||||
"temperature_celsius": {"type": "float", "required": False, "label_de": "Temperatur (°C)"},
|
||||
"humidity_percent": {"type": "int", "required": False, "label_de": "Luftfeuchtigkeit (%)"},
|
||||
"avg_hr_percent": {"type": "float", "required": False, "label_de": "HF Ø (% von max)"},
|
||||
"kcal_per_km": {"type": "float", "required": False, "label_de": "Kalorien pro km"},
|
||||
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
|
||||
},
|
||||
"derive_date_from_datetime_field": "start_time",
|
||||
"duplicate_key": ["profile_id", "date", "start_time"],
|
||||
|
|
@ -125,13 +152,16 @@ def list_modules() -> list[str]:
|
|||
return sorted(MODULE_DEFINITIONS.keys())
|
||||
|
||||
|
||||
def validate_field_mappings(module: str, field_mappings: dict) -> None:
|
||||
def validate_field_mappings(module: str, field_mappings: dict, cur=None) -> None:
|
||||
"""Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld."""
|
||||
mod = get_module_definition(module)
|
||||
if not mod:
|
||||
raise ValueError(f"Unbekanntes Modul: {module}")
|
||||
fields = cast(dict, mod["fields"])
|
||||
allowed = set(fields.keys())
|
||||
if module == "activity" and cur is not None:
|
||||
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
|
||||
allowed.update(str(r["key"]) for r in cur.fetchall())
|
||||
if not allowed:
|
||||
for _csv_col, db_field in field_mappings.items():
|
||||
if db_field not in ("", None, "-", "_skip"):
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from csv_parser.module_registry import (
|
|||
validate_field_mappings,
|
||||
validate_required_field_targets,
|
||||
)
|
||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
||||
|
||||
ALLOWED_SPEC_TYPES = frozenset(
|
||||
{"string", "float", "number", "int", "date", "time", "datetime", "duration"}
|
||||
|
|
@ -50,6 +51,8 @@ def validate_csv_template(
|
|||
type_conversions: Mapping[str, Any] | None = None,
|
||||
import_row_processing: Mapping[str, Any] | None = None,
|
||||
column_signature: list[str] | None = None,
|
||||
*,
|
||||
cur=None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Prüft eine Vorlage ohne Datei-Upload.
|
||||
|
|
@ -74,8 +77,12 @@ def validate_csv_template(
|
|||
)
|
||||
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||
|
||||
field_defs = dict(mod.get("fields") or {})
|
||||
if module == "activity" and cur is not None:
|
||||
field_defs = merge_activity_csv_module_fields(cur, field_defs)
|
||||
|
||||
try:
|
||||
validate_field_mappings(module, fm)
|
||||
validate_field_mappings(module, fm, cur=cur)
|
||||
except ValueError as e:
|
||||
errors.append(
|
||||
_issue(
|
||||
|
|
@ -100,7 +107,7 @@ def validate_csv_template(
|
|||
|
||||
if import_row_processing:
|
||||
try:
|
||||
validate_import_row_processing_spec(module, import_row_processing, fm)
|
||||
validate_import_row_processing_spec(module, import_row_processing, fm, cur=cur)
|
||||
except ValueError as e:
|
||||
errors.append(
|
||||
_issue(
|
||||
|
|
@ -111,7 +118,6 @@ def validate_csv_template(
|
|||
)
|
||||
)
|
||||
|
||||
field_defs = mod.get("fields") or {}
|
||||
for db_field, spec in tc.items():
|
||||
if db_field not in field_defs:
|
||||
errors.append(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from typing import Any, Mapping, Sequence
|
|||
|
||||
from dateutil import parser as dateutil_parser
|
||||
|
||||
from csv_parser.core import normalize_header_for_signature
|
||||
from csv_parser.core import canonical_csv_header_label, normalize_header_for_signature
|
||||
from csv_parser.field_units import factor_source_to_canonical
|
||||
|
||||
# Alias → strptime (JSON in Kleinbuchstaben)
|
||||
|
|
@ -477,7 +477,12 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
|
|||
CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert
|
||||
(wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
|
||||
dann Abgleich aller Vorlagen-Keys über deren Normalform.
|
||||
|
||||
Zusätzlich: Präfix-Treffer für lange manuelle Keys (z. B. Apple
|
||||
„Aufgestiegene Höhe (m)“ → ``aufgestiegene_höhe_(m)`` vs. Mapping
|
||||
„aufgestiegene Höhe“ → ``aufgestiegene_höhe``) — gewinnt der längste passende Key.
|
||||
"""
|
||||
csv_col = canonical_csv_header_label(csv_col)
|
||||
v = field_mappings.get(csv_col)
|
||||
if v:
|
||||
return v if v not in ("-", "_skip") else None
|
||||
|
|
@ -488,6 +493,27 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
|
|||
for k, fv in field_mappings.items():
|
||||
if normalize_header_for_signature(str(k)) == norm:
|
||||
return fv if fv not in ("-", "_skip") else None
|
||||
|
||||
# Präfix-Match (min. Länge gegen false positives wie „datum“ → „datum_xyz“)
|
||||
best_fv: str | None = None
|
||||
best_nk_len = 0
|
||||
min_prefix = 10
|
||||
for k, fv in field_mappings.items():
|
||||
if not fv or fv in ("-", "_skip"):
|
||||
continue
|
||||
nk = normalize_header_for_signature(str(k))
|
||||
if len(nk) < min_prefix or len(nk) >= len(norm):
|
||||
continue
|
||||
if not norm.startswith(nk):
|
||||
continue
|
||||
boundary = norm[len(nk) : len(nk) + 1]
|
||||
if boundary not in ("", "_", "("):
|
||||
continue
|
||||
if len(nk) > best_nk_len:
|
||||
best_nk_len = len(nk)
|
||||
best_fv = fv
|
||||
if best_fv:
|
||||
return best_fv
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -123,7 +124,8 @@ def get_activity_detail_data(
|
|||
"duration_min": int,
|
||||
"kcal_active": int,
|
||||
"hr_avg": int | None,
|
||||
"training_category": str | None
|
||||
"training_category": str | None,
|
||||
"session_metrics": list | None, # EAV (enrich_sessions_with_metrics)
|
||||
},
|
||||
...
|
||||
],
|
||||
|
|
@ -142,6 +144,7 @@ def get_activity_detail_data(
|
|||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
id,
|
||||
date,
|
||||
activity_type,
|
||||
duration_min,
|
||||
|
|
@ -152,7 +155,7 @@ def get_activity_detail_data(
|
|||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC
|
||||
LIMIT %s""",
|
||||
(profile_id, cutoff, limit)
|
||||
(profile_id, cutoff, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
|
|
@ -161,19 +164,24 @@ def get_activity_detail_data(
|
|||
"activities": [],
|
||||
"total_count": 0,
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days
|
||||
"days_analyzed": days,
|
||||
}
|
||||
|
||||
activities = []
|
||||
for row in rows:
|
||||
activities.append({
|
||||
"date": row['date'],
|
||||
"activity_type": row['activity_type'],
|
||||
"duration_min": safe_int(row['duration_min']),
|
||||
"kcal_active": safe_int(row['kcal_active']),
|
||||
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
|
||||
"training_category": row.get('training_category')
|
||||
})
|
||||
activities.append(
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"date": row["date"],
|
||||
"activity_type": row["activity_type"],
|
||||
"duration_min": safe_int(row["duration_min"]),
|
||||
"kcal_active": safe_int(row["kcal_active"]),
|
||||
"hr_avg": safe_int(row["hr_avg"]) if row.get("hr_avg") else None,
|
||||
"training_category": row.get("training_category"),
|
||||
}
|
||||
)
|
||||
|
||||
enrich_sessions_with_metrics(cur, activities)
|
||||
|
||||
confidence = calculate_confidence(len(activities), days, "general")
|
||||
|
||||
|
|
@ -181,7 +189,7 @@ def get_activity_detail_data(
|
|||
"activities": activities,
|
||||
"total_count": len(activities),
|
||||
"confidence": confidence,
|
||||
"days_analyzed": days
|
||||
"days_analyzed": days,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1112,6 +1120,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 +1150,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 +1161,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", []),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
410
backend/data_layer/activity_persistence_orchestrator.py
Normal file
410
backend/data_layer/activity_persistence_orchestrator.py
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
"""
|
||||
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, Spalten→EAV).
|
||||
|
||||
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.
|
||||
|
||||
Feld-Katalog für CSV-Mappings: get_mappable_activity_field_catalog()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from models import ActivityEntry
|
||||
|
||||
from csv_parser.module_registry import get_module_definition
|
||||
from data_layer.activity_session_metrics import sync_column_backed_session_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
|
||||
|
||||
_EVALUATION_AVAILABLE = True
|
||||
except Exception: # pragma: no cover
|
||||
_evaluate_and_save_activity = None
|
||||
_EVALUATION_AVAILABLE = False
|
||||
|
||||
|
||||
def find_activity_duplicate_id(
|
||||
cur,
|
||||
profile_id: str,
|
||||
date_iso: str,
|
||||
start_time: Optional[Any],
|
||||
) -> Optional[str]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM activity_log
|
||||
WHERE profile_id = %s AND date = %s::date
|
||||
AND start_time IS NOT DISTINCT FROM %s::time
|
||||
""",
|
||||
(profile_id, date_iso, start_time),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return str(row["id"]) if row else None
|
||||
|
||||
|
||||
# Datum/Start/Ende/Typ setzt der CSV-Executor explizit (Normalisierung); nicht aus diesem Patch überschreiben.
|
||||
_ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "activity_type"})
|
||||
|
||||
|
||||
def activity_registry_field_keys() -> frozenset[str]:
|
||||
mod = get_module_definition("activity")
|
||||
if not mod:
|
||||
return frozenset()
|
||||
return frozenset((mod.get("fields") or {}).keys())
|
||||
|
||||
|
||||
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
activity_log-Updates nur aus Modul-Registry-Feldern (Kernspalten).
|
||||
Trainingsparameter-Keys (nur in training_parameters) laufen über EAV, nicht hier.
|
||||
"""
|
||||
mod = get_module_definition("activity")
|
||||
if not mod:
|
||||
return {}
|
||||
fields = mod.get("fields") or {}
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
def _sf(v: Any) -> float | None:
|
||||
try:
|
||||
if v is None or (isinstance(v, str) and not str(v).strip()):
|
||||
return None
|
||||
return round(float(v), 1)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _si(v: Any) -> int | None:
|
||||
try:
|
||||
if v is None or (isinstance(v, str) and not str(v).strip()):
|
||||
return None
|
||||
return int(round(float(v)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _hr(v: Any) -> float | None:
|
||||
x = _sf(v)
|
||||
if x is None or x < 20 or x > 280:
|
||||
return None
|
||||
return x
|
||||
|
||||
for key, spec in fields.items():
|
||||
if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE:
|
||||
continue
|
||||
if key not in mapped:
|
||||
continue
|
||||
raw = mapped[key]
|
||||
if raw is None or raw == "":
|
||||
continue
|
||||
if isinstance(raw, str) and not raw.strip():
|
||||
continue
|
||||
typ = spec.get("type", "string")
|
||||
if typ == "float":
|
||||
v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw)
|
||||
if v is not None:
|
||||
out[key] = v
|
||||
elif typ == "int":
|
||||
v = _si(raw)
|
||||
if v is not None:
|
||||
out[key] = v
|
||||
elif typ == "datetime":
|
||||
if isinstance(raw, dt.datetime):
|
||||
out[key] = raw.strftime("%Y-%m-%d %H:%M:%S")
|
||||
elif isinstance(raw, dt.date):
|
||||
out[key] = f"{raw.isoformat()} 00:00:00"
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
out[key] = raw.strip()
|
||||
elif typ == "date":
|
||||
if isinstance(raw, dt.date):
|
||||
out[key] = raw.isoformat()
|
||||
elif isinstance(raw, dt.datetime):
|
||||
out[key] = raw.date().isoformat()
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
out[key] = raw.strip()
|
||||
else:
|
||||
out[key] = str(raw).strip()
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def insert_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
|
||||
"""INSERT activity_log aus ActivityEntry (manueller API-Pfad)."""
|
||||
d = e.model_dump()
|
||||
cur.execute(
|
||||
"""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||
hr_avg,hr_max,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain,
|
||||
temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_km,rpe,source,notes,
|
||||
training_type_id,training_category,training_subcategory,created)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||
(
|
||||
eid,
|
||||
profile_id,
|
||||
d["date"],
|
||||
d["start_time"],
|
||||
d["end_time"],
|
||||
d["activity_type"],
|
||||
d["duration_min"],
|
||||
d["kcal_active"],
|
||||
d["kcal_resting"],
|
||||
d["hr_avg"],
|
||||
d["hr_max"],
|
||||
d.get("hr_min"),
|
||||
d["distance_km"],
|
||||
d.get("pace_min_per_km"),
|
||||
d.get("cadence"),
|
||||
d.get("avg_power"),
|
||||
d.get("elevation_gain"),
|
||||
d.get("temperature_celsius"),
|
||||
d.get("humidity_percent"),
|
||||
d.get("avg_hr_percent"),
|
||||
d.get("kcal_per_km"),
|
||||
d["rpe"],
|
||||
d["source"],
|
||||
d["notes"],
|
||||
d.get("training_type_id"),
|
||||
d.get("training_category"),
|
||||
d.get("training_subcategory"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def update_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
|
||||
"""Volles UPDATE aus ActivityEntry (REST PUT)."""
|
||||
d = e.model_dump()
|
||||
cur.execute(
|
||||
f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
||||
list(d.values()) + [eid, profile_id],
|
||||
)
|
||||
|
||||
|
||||
def update_activity_columns(
|
||||
cur,
|
||||
profile_id: str,
|
||||
eid: str,
|
||||
updates: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Teil-UPDATE nur für übergebene Spalten (Importe)."""
|
||||
if not updates:
|
||||
return
|
||||
cols = [f"{k} = %s" for k in updates]
|
||||
vals = list(updates.values()) + [eid, profile_id]
|
||||
cur.execute(
|
||||
f"UPDATE activity_log SET {', '.join(cols)} WHERE id = %s AND profile_id = %s",
|
||||
vals,
|
||||
)
|
||||
|
||||
|
||||
def insert_activity_csv_minimal(
|
||||
cur,
|
||||
profile_id: str,
|
||||
eid: str,
|
||||
*,
|
||||
date_iso: str,
|
||||
start_time: Any,
|
||||
end_time: Any,
|
||||
activity_type: str,
|
||||
duration_min: Any,
|
||||
kcal_active: Any,
|
||||
kcal_resting: Any,
|
||||
hr_avg: Any,
|
||||
hr_max: Any,
|
||||
distance_km: Any,
|
||||
training_type_id: Any,
|
||||
training_category: Any,
|
||||
training_subcategory: Any,
|
||||
source: str,
|
||||
) -> None:
|
||||
"""INSERT minimale activity_log-Zeile (Universal-CSV)."""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO activity_log (
|
||||
id, profile_id, date, start_time, end_time, activity_type, duration_min,
|
||||
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
|
||||
source, training_type_id, training_category, training_subcategory, created
|
||||
)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
eid,
|
||||
profile_id,
|
||||
date_iso,
|
||||
start_time,
|
||||
end_time,
|
||||
activity_type,
|
||||
duration_min,
|
||||
kcal_active,
|
||||
kcal_resting,
|
||||
hr_avg,
|
||||
hr_max,
|
||||
distance_km,
|
||||
source,
|
||||
training_type_id,
|
||||
training_category,
|
||||
training_subcategory,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
|
||||
"""Auto-Eval (falls aktiv) + EAV-Spiegel aus activity_log-Spalten."""
|
||||
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
||||
rpe, pace_min_per_km, cadence, elevation_gain
|
||||
FROM activity_log
|
||||
WHERE id = %s
|
||||
""",
|
||||
(eid,),
|
||||
)
|
||||
activity_row = cur.fetchone()
|
||||
if activity_row:
|
||||
activity_dict = dict(activity_row)
|
||||
training_type_id = activity_dict.get("training_type_id")
|
||||
if training_type_id:
|
||||
try:
|
||||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
||||
except Exception as eval_error:
|
||||
logger.error("[AUTO-EVAL] activity %s: %s", eid, eval_error)
|
||||
sync_column_backed_session_metrics(cur, str(profile_id), str(eid))
|
||||
|
||||
|
||||
def run_activity_post_write_hooks_import(
|
||||
cur,
|
||||
profile_id: str,
|
||||
eid: str,
|
||||
*,
|
||||
workout_date: str,
|
||||
training_type_id: Optional[int],
|
||||
duration_min: Any,
|
||||
hr_avg: Any,
|
||||
hr_max: Any,
|
||||
distance_km: Any,
|
||||
kcal_active: Any,
|
||||
kcal_resting: Any,
|
||||
) -> None:
|
||||
"""Eval + EAV nach Legacy-Import mit vorgebautem Kontext-Dict."""
|
||||
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
||||
try:
|
||||
activity_dict = {
|
||||
"id": eid,
|
||||
"profile_id": profile_id,
|
||||
"date": workout_date,
|
||||
"training_type_id": training_type_id,
|
||||
"duration_min": duration_min,
|
||||
"hr_avg": hr_avg,
|
||||
"hr_max": hr_max,
|
||||
"distance_km": distance_km,
|
||||
"kcal_active": kcal_active,
|
||||
"kcal_resting": kcal_resting,
|
||||
"rpe": None,
|
||||
"pace_min_per_km": None,
|
||||
"cadence": None,
|
||||
"elevation_gain": None,
|
||||
}
|
||||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
||||
except Exception as eval_err:
|
||||
logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", eval_err)
|
||||
sync_column_backed_session_metrics(cur, str(profile_id), str(eid))
|
||||
|
||||
|
||||
def merge_activity_csv_module_fields(
|
||||
cur,
|
||||
static_fields: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
activity-Modul für CSV: statische Registry-Felder + alle aktiven training_parameters.
|
||||
|
||||
Gleiche Quelle wie get_mappable_activity_field_catalog.training_parameters — erscheint
|
||||
in Admin-CSV-Ziel-Liste, Validierung und Import-Zeilenaggregation.
|
||||
"""
|
||||
out = dict(static_fields)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT key, data_type, unit, name_de
|
||||
FROM training_parameters
|
||||
WHERE is_active = true
|
||||
ORDER BY key
|
||||
"""
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
k = row["key"]
|
||||
if k in out:
|
||||
continue
|
||||
dt = row["data_type"] or "float"
|
||||
if dt == "integer":
|
||||
mtype = "int"
|
||||
elif dt == "float":
|
||||
mtype = "float"
|
||||
elif dt == "boolean":
|
||||
mtype = "string"
|
||||
else:
|
||||
mtype = "string"
|
||||
spec: Dict[str, Any] = {
|
||||
"type": mtype,
|
||||
"required": False,
|
||||
"from_training_parameter": True,
|
||||
}
|
||||
if row.get("unit"):
|
||||
spec["unit"] = row["unit"]
|
||||
if row.get("name_de"):
|
||||
spec["label_de"] = row["name_de"]
|
||||
out[k] = spec
|
||||
return out
|
||||
|
||||
|
||||
def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Felder für konfigurierbare Import-Mappings.
|
||||
|
||||
core_fields: module_registry „activity“ → activity_log.
|
||||
training_parameters: alle aktiven Parameter (global); bei Anwendung auf eine Session
|
||||
werden Keys verworfen, die nicht in resolve_activity_attribute_schema(Kategorie/Typ) liegen.
|
||||
|
||||
profile_id: reserviert für künftige Profil-Filter.
|
||||
"""
|
||||
_ = profile_id
|
||||
mod = get_module_definition("activity") or {}
|
||||
fields = mod.get("fields") or {}
|
||||
core_fields: List[Dict[str, Any]] = []
|
||||
for key, spec in fields.items():
|
||||
s = spec or {}
|
||||
core_fields.append(
|
||||
{
|
||||
"key": key,
|
||||
"target": "activity_log",
|
||||
"column": key,
|
||||
"data_type": s.get("type", "string"),
|
||||
"required": bool(s.get("required")),
|
||||
"unit": s.get("unit"),
|
||||
"label_de": s.get("label_de") or key,
|
||||
}
|
||||
)
|
||||
core_fields.sort(key=lambda x: x["key"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, key, name_de, name_en, category AS param_category,
|
||||
data_type, unit, source_field
|
||||
FROM training_parameters
|
||||
WHERE is_active = true
|
||||
ORDER BY key
|
||||
"""
|
||||
)
|
||||
parameters = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"core_fields": core_fields,
|
||||
"training_parameters": parameters,
|
||||
"notes": (
|
||||
"training_parameters listet alle aktiven Keys. Pro Session werden Werte ignoriert, "
|
||||
"die für deren training_category/training_type_id nicht im Attribut-Schema vorkommen."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def new_activity_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
610
backend/data_layer/activity_session_metrics.py
Normal file
610
backend/data_layer/activity_session_metrics.py
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
"""
|
||||
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
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
from csv_parser.module_registry import get_module_definition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# activity_log-Spalten, die per training_parameters.source_field aus CSV (Parameter-Key) befüllt werden dürfen.
|
||||
# Muss mit sync_column_backed_session_metrics übereinstimmen (inkl. Kernmetriken wie hr_avg).
|
||||
ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset(
|
||||
{
|
||||
"start_time",
|
||||
"end_time",
|
||||
"activity_type",
|
||||
"duration_min",
|
||||
"kcal_active",
|
||||
"kcal_resting",
|
||||
"hr_avg",
|
||||
"hr_max",
|
||||
"hr_min",
|
||||
"distance_km",
|
||||
"rpe",
|
||||
"pace_min_per_km",
|
||||
"cadence",
|
||||
"avg_power",
|
||||
"elevation_gain",
|
||||
"temperature_celsius",
|
||||
"humidity_percent",
|
||||
"avg_hr_percent",
|
||||
"kcal_per_km",
|
||||
"notes",
|
||||
}
|
||||
)
|
||||
|
||||
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
|
||||
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
|
||||
{
|
||||
"id",
|
||||
"profile_id",
|
||||
"date",
|
||||
"created",
|
||||
"training_type_id",
|
||||
"training_category",
|
||||
"training_subcategory",
|
||||
"source",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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 _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
|
||||
"""Wert aus activity_log-Spalte in den Typ bringen, den training_parameters.data_type erwartet."""
|
||||
if data_type == "integer":
|
||||
if isinstance(raw, bool):
|
||||
raise TypeError("boolean nicht als integer erlaubt")
|
||||
if isinstance(raw, str):
|
||||
s = raw.strip().replace(",", ".")
|
||||
return int(round(float(s)))
|
||||
return int(round(float(raw)))
|
||||
if data_type == "float":
|
||||
if isinstance(raw, str):
|
||||
s = raw.strip().replace(",", ".")
|
||||
return float(s)
|
||||
return float(raw)
|
||||
if data_type == "string":
|
||||
return str(raw) if raw is not None else ""
|
||||
if data_type == "boolean":
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
s = str(raw).strip().lower()
|
||||
if s in ("true", "1", "t", "yes"):
|
||||
return True
|
||||
if s in ("false", "0", "f", "no", ""):
|
||||
return False
|
||||
raise TypeError(f"boolean-Koercion nicht möglich: {raw!r}")
|
||||
raise ValueError(data_type)
|
||||
|
||||
|
||||
def upsert_session_metrics_from_csv_mapped(
|
||||
cur,
|
||||
profile_id: str,
|
||||
activity_log_id: str,
|
||||
mapped: Mapping[str, Any],
|
||||
training_category: Optional[str],
|
||||
training_type_id: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen).
|
||||
|
||||
Kernfelder (Datum, Start, Distanz, HF, …) schreibt der Executor nach activity_log;
|
||||
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys.
|
||||
"""
|
||||
cur.execute(
|
||||
"SELECT profile_id FROM activity_log WHERE id = %s",
|
||||
(activity_log_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or str(row["profile_id"]) != str(profile_id):
|
||||
return
|
||||
mod = get_module_definition("activity") or {}
|
||||
activity_registry_keys = frozenset((mod.get("fields") or {}).keys())
|
||||
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
|
||||
for spec in schema:
|
||||
pkey = spec["key"]
|
||||
if pkey not in mapped:
|
||||
continue
|
||||
raw = mapped[pkey]
|
||||
if raw is None or raw == "":
|
||||
continue
|
||||
if pkey in activity_registry_keys:
|
||||
continue
|
||||
tid = spec["training_parameter_id"]
|
||||
dt = spec["data_type"]
|
||||
rules = _validation_rules_dict(spec["validation_rules"])
|
||||
try:
|
||||
coerced = _coerce_raw_value_for_parameter(dt, raw)
|
||||
_validate_single_value(dt, coerced, rules)
|
||||
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
|
||||
logger.warning("CSV EAV skipped %s: %s", pkey, ex)
|
||||
continue
|
||||
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
|
||||
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())
|
||||
ON CONFLICT (activity_log_id, training_parameter_id)
|
||||
DO UPDATE SET
|
||||
value_num = EXCLUDED.value_num,
|
||||
value_int = EXCLUDED.value_int,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_bool = EXCLUDED.value_bool,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(activity_log_id, tid, vn, vi, vt, vb),
|
||||
)
|
||||
|
||||
|
||||
def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None:
|
||||
"""
|
||||
EAV-Zeilen für alle Schema-Parameter mit gesetztem source_field aus der activity_log-Zeile
|
||||
schreiben (Upsert) bzw. bei NULL in der Quellspalte löschen. Reine Layer-1-Logik; keine Router-Abhängigkeit.
|
||||
|
||||
Synchron mit Übergangsphase: activity_log bleibt kanonisch für klassische Spalten; EAV spiegelt dieselben
|
||||
Werte für Profil/Platzhalter/Detail-API, ohne replace_activity_session_metrics aufzurufen.
|
||||
"""
|
||||
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):
|
||||
return
|
||||
header = dict(row)
|
||||
schema = resolve_activity_attribute_schema(
|
||||
cur, header.get("training_category"), header.get("training_type_id")
|
||||
)
|
||||
for spec in schema:
|
||||
sf = spec.get("source_field")
|
||||
if sf is None or (isinstance(sf, str) and not str(sf).strip()):
|
||||
continue
|
||||
col = str(sf).strip()
|
||||
if col not in header:
|
||||
continue
|
||||
raw = header[col]
|
||||
tid = spec["training_parameter_id"]
|
||||
dt = spec["data_type"]
|
||||
rules = _validation_rules_dict(spec["validation_rules"])
|
||||
|
||||
if raw is None:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM activity_session_metrics
|
||||
WHERE activity_log_id = %s AND training_parameter_id = %s
|
||||
""",
|
||||
(activity_log_id, tid),
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
coerced = _coerce_raw_value_for_parameter(dt, raw)
|
||||
_validate_single_value(dt, coerced, rules)
|
||||
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
|
||||
logger.warning(
|
||||
"sync_column_backed_session_metrics: überspringe %s (Spalte %s): %s",
|
||||
spec.get("key"),
|
||||
col,
|
||||
ex,
|
||||
)
|
||||
continue
|
||||
|
||||
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
|
||||
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())
|
||||
ON CONFLICT (activity_log_id, training_parameter_id)
|
||||
DO UPDATE SET
|
||||
value_num = EXCLUDED.value_num,
|
||||
value_int = EXCLUDED.value_int,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_bool = EXCLUDED.value_bool,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(activity_log_id, tid, vn, vi, vt, vb),
|
||||
)
|
||||
|
||||
|
||||
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_by_key: Dict[str, Dict[str, Any]] = {}
|
||||
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_by_key[k] = item
|
||||
|
||||
for s in schema:
|
||||
if not s["required"]:
|
||||
continue
|
||||
itk = s["key"]
|
||||
hit = payload_by_key.get(itk)
|
||||
if hit is None or hit.get("value") is None:
|
||||
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
|
||||
|
||||
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]
|
||||
val = item.get("value")
|
||||
if val is None:
|
||||
if spec["required"]:
|
||||
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}")
|
||||
continue
|
||||
rules = _validation_rules_dict(spec["validation_rules"])
|
||||
_validate_single_value(spec["data_type"], val, rules)
|
||||
vn, vi, vt, vb = _row_value_tuple(spec["data_type"], val)
|
||||
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),
|
||||
)
|
||||
|
||||
# Kein sync_column_backed nach PUT /metrics: der Request ist maßgeblich für EAV. Ein Spalten-Sync würde
|
||||
# Werte aus nicht mitgeschriebenen activity_log-Spalten wieder verwerfen.
|
||||
|
||||
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)
|
||||
by_key = {m["key"]: m for m in metrics}
|
||||
merged_metrics: List[Dict[str, Any]] = list(metrics)
|
||||
for s in schema:
|
||||
k = s["key"]
|
||||
if k in by_key:
|
||||
continue
|
||||
sf = s.get("source_field")
|
||||
if not sf or (isinstance(sf, str) and not str(sf).strip()):
|
||||
continue
|
||||
col = str(sf).strip()
|
||||
if col not in header:
|
||||
continue
|
||||
raw = header.get(col)
|
||||
if raw is None:
|
||||
continue
|
||||
dt = s["data_type"]
|
||||
try:
|
||||
val = _coerce_raw_value_for_parameter(dt, raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
merged_metrics.append(
|
||||
{
|
||||
"training_parameter_id": s["training_parameter_id"],
|
||||
"key": k,
|
||||
"data_type": dt,
|
||||
"unit": s.get("unit"),
|
||||
"value": val,
|
||||
}
|
||||
)
|
||||
merged_metrics.sort(key=lambda x: x["key"])
|
||||
return {
|
||||
"header": header,
|
||||
"schema": schema,
|
||||
"metrics": merged_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, [])
|
||||
30
backend/data_layer/activity_time_normalize.py
Normal file
30
backend/data_layer/activity_time_normalize.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""
|
||||
Einheitliche Startzeit-Normalisierung für Aktivität (CSV, Legacy-Import, Dedupe).
|
||||
|
||||
Anbieter-agnostisch: beliebige ISO-/Export-Strings über dateutil.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time as dt_time
|
||||
from typing import Optional
|
||||
|
||||
from dateutil import parser as du_parser
|
||||
|
||||
|
||||
def normalize_activity_start(start_raw: str) -> tuple[str, Optional[dt_time]]:
|
||||
"""
|
||||
Roh-String „Start“ aus Exporten → (YYYY-MM-DD, TIME ohne μs) für DB Dedupe/INSERT.
|
||||
|
||||
Leerer Input → ("", None). Fallback bei Parse-Fehler: erstes Datum aus ersten 10 Zeichen.
|
||||
"""
|
||||
s = (start_raw or "").strip()
|
||||
if not s:
|
||||
return "", None
|
||||
try:
|
||||
parsed = du_parser.parse(s, dayfirst=False)
|
||||
t = parsed.time().replace(microsecond=0)
|
||||
return parsed.date().isoformat(), t
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
if len(s) >= 10:
|
||||
return s[:10], None
|
||||
return "", None
|
||||
|
|
@ -29,7 +29,9 @@ def init_pool():
|
|||
user=os.getenv("DB_USER", "mitai"),
|
||||
password=os.getenv("DB_PASSWORD", "")
|
||||
)
|
||||
print(f"✓ PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})")
|
||||
print(
|
||||
f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -171,7 +173,7 @@ def init_db():
|
|||
) as table_exists
|
||||
""")
|
||||
if not cur.fetchone()['table_exists']:
|
||||
print("⚠️ ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
|
||||
print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
|
||||
return
|
||||
|
||||
# Ensure "pipeline" master prompt exists
|
||||
|
|
@ -189,7 +191,7 @@ def init_db():
|
|||
)
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ Pipeline master prompt created")
|
||||
print("[OK] Pipeline master prompt created")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not create pipeline prompt: {e}")
|
||||
print(f"[WARN] Could not create pipeline prompt: {e}")
|
||||
# Don't fail startup - prompt can be created manually
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
@ -66,7 +67,7 @@ async def startup_event():
|
|||
try:
|
||||
init_db()
|
||||
except Exception as e:
|
||||
print(f"⚠️ init_db() failed (non-fatal): {e}")
|
||||
print(f"[WARN] init_db() failed (non-fatal): {e}")
|
||||
# Don't crash on startup - can be created manually
|
||||
|
||||
# Apply v9c migration if needed
|
||||
|
|
@ -74,7 +75,7 @@ async def startup_event():
|
|||
from apply_v9c_migration import apply_migration
|
||||
apply_migration()
|
||||
except Exception as e:
|
||||
print(f"⚠️ v9c migration failed (non-fatal): {e}")
|
||||
print(f"[WARN] v9c migration failed (non-fatal): {e}")
|
||||
|
||||
# ── Register Routers ──────────────────────────────────────────────────────────
|
||||
app.include_router(auth.router) # /api/auth/*
|
||||
|
|
@ -128,6 +129,8 @@ app.include_router(admin_reference_value_types.router) # /api/admin/reference-v
|
|||
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
|
||||
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
|
||||
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
|
||||
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
|
||||
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
80
backend/migrations/054_activity_session_metrics_eav.sql
Normal file
80
backend/migrations/054_activity_session_metrics_eav.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- Migration 054: Activity session metrics (EAV) + attribute profiles
|
||||
-- Date: 2026-04-14
|
||||
-- Additive only: safe for production (no data deletion).
|
||||
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
||||
|
||||
-- Session interval (nullable; optional backfill later)
|
||||
ALTER TABLE activity_log
|
||||
ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_profile_started
|
||||
ON activity_log (profile_id, started_at DESC)
|
||||
WHERE started_at IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN activity_log.started_at IS 'Training start (wall clock, TZ-aware); optional; for dedupe/analysis';
|
||||
COMMENT ON COLUMN activity_log.ended_at IS 'Training end (wall clock, TZ-aware); optional';
|
||||
|
||||
-- Which parameters apply to which training category (training_types.category)
|
||||
CREATE TABLE IF NOT EXISTS training_category_parameter (
|
||||
id SERIAL PRIMARY KEY,
|
||||
training_category VARCHAR(50) NOT NULL,
|
||||
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
required BOOLEAN NOT NULL DEFAULT false,
|
||||
ui_group VARCHAR(50),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_training_category_parameter UNIQUE (training_category, training_parameter_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tcp_category ON training_category_parameter (training_category);
|
||||
|
||||
COMMENT ON TABLE training_category_parameter IS 'EAV schema: parameters enabled per training category';
|
||||
|
||||
-- Per training type: extra parameters or overrides (NULL sort/required/ui = inherit from category row if present)
|
||||
CREATE TABLE IF NOT EXISTS training_type_parameter (
|
||||
id SERIAL PRIMARY KEY,
|
||||
training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
|
||||
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
|
||||
sort_order INT,
|
||||
required BOOLEAN,
|
||||
ui_group VARCHAR(50),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_training_type_parameter UNIQUE (training_type_id, training_parameter_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ttp_type ON training_type_parameter (training_type_id);
|
||||
|
||||
COMMENT ON TABLE training_type_parameter IS 'EAV schema: add/override parameters for a concrete training_types row';
|
||||
|
||||
-- EAV values per activity session
|
||||
CREATE TABLE IF NOT EXISTS activity_session_metrics (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
activity_log_id UUID NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE,
|
||||
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE RESTRICT,
|
||||
value_num DOUBLE PRECISION,
|
||||
value_int BIGINT,
|
||||
value_text TEXT,
|
||||
value_bool BOOLEAN,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_activity_session_metric UNIQUE (activity_log_id, training_parameter_id),
|
||||
CONSTRAINT chk_activity_session_metric_one_value CHECK (
|
||||
(
|
||||
(value_num IS NOT NULL)::int
|
||||
+ (value_int IS NOT NULL)::int
|
||||
+ (value_text IS NOT NULL)::int
|
||||
+ (value_bool IS NOT NULL)::int
|
||||
) = 1
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_asm_activity ON activity_session_metrics (activity_log_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asm_parameter ON activity_session_metrics (training_parameter_id);
|
||||
|
||||
COMMENT ON TABLE activity_session_metrics IS 'EAV: one row per (session, training_parameter); exactly one value_* set';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 054: activity_session_metrics EAV + attribute profile tables + activity_log timestamps';
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
-- Migration 055: Seed training_category_parameter (all categories × parameters with activity_log source_field)
|
||||
-- + idempotent backfill activity_log → activity_session_metrics (EAV)
|
||||
-- Date: 2026-04-15
|
||||
-- SAFE: INSERT … ON CONFLICT DO NOTHING only; no DELETE/TRUNCATE on activity_log.
|
||||
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
||||
|
||||
--1) Jede in training_types vorkommende Kategorie erhält alle aktiven Parameter mit source_field (Spalte in activity_log).
|
||||
INSERT INTO training_category_parameter (
|
||||
training_category,
|
||||
training_parameter_id,
|
||||
sort_order,
|
||||
required,
|
||||
ui_group
|
||||
)
|
||||
SELECT
|
||||
tc.training_category,
|
||||
tp.id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY tc.training_category
|
||||
ORDER BY tp.category, tp.id
|
||||
),
|
||||
false,
|
||||
NULL
|
||||
FROM (
|
||||
SELECT DISTINCT category AS training_category
|
||||
FROM training_types
|
||||
WHERE category IS NOT NULL AND trim(category) <> ''
|
||||
) tc
|
||||
CROSS JOIN training_parameters tp
|
||||
WHERE tp.is_active = true
|
||||
AND tp.source_field IS NOT NULL
|
||||
AND trim(tp.source_field) <> ''
|
||||
ON CONFLICT (training_category, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- 2) Backfill: activity_log-Spalten → EAV (nur wenn noch keine Zeile existiert)
|
||||
|
||||
-- duration_min → integer
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT
|
||||
a.id,
|
||||
tp.id,
|
||||
NULL,
|
||||
ROUND(a.duration_min::numeric)::bigint,
|
||||
NULL,
|
||||
NULL,
|
||||
NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'duration_min' AND tp.is_active = true
|
||||
WHERE a.duration_min IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- distance_km → float
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.distance_km::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'distance_km' AND tp.is_active = true
|
||||
WHERE a.distance_km IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- kcal_active
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.kcal_active::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'kcal_active' AND tp.is_active = true
|
||||
WHERE a.kcal_active IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.kcal_resting::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'kcal_resting' AND tp.is_active = true
|
||||
WHERE a.kcal_resting IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- hr_avg / hr_max → keys avg_hr, max_hr
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.hr_avg::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'avg_hr' AND tp.is_active = true
|
||||
WHERE a.hr_avg IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.hr_max::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'max_hr' AND tp.is_active = true
|
||||
WHERE a.hr_max IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- rpe
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.rpe::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'rpe' AND tp.is_active = true
|
||||
WHERE a.rpe IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- min_hr (Spalte hr_min nach Migration 014)
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true
|
||||
WHERE a.hr_min IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true
|
||||
WHERE a.pace_min_per_km IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true
|
||||
WHERE a.cadence IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true
|
||||
WHERE a.avg_power IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true
|
||||
WHERE a.elevation_gain IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true
|
||||
WHERE a.temperature_celsius IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true
|
||||
WHERE a.humidity_percent IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true
|
||||
WHERE a.avg_hr_percent IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true
|
||||
WHERE a.kcal_per_km IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 055: category parameter seed + EAV backfill from activity_log (no row deletes)';
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
-- Migration 056: kcal_per_km Trigger — manuelles Leeren bei UPDATE erlauben
|
||||
-- Problem: calculate_avg_hr_percent (014) setzte bei jedem UPDATE kcal_per_km aus
|
||||
-- kcal_active/distance_km, sobald beide gesetzt waren — ein bewusst geleertes Feld
|
||||
-- erschien sofort wieder.
|
||||
-- Lösung: automatische Ableitung nur noch bei INSERT (wenn kcal_per_km noch NULL ist).
|
||||
|
||||
CREATE OR REPLACE FUNCTION calculate_avg_hr_percent()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_max_hr INTEGER;
|
||||
BEGIN
|
||||
SELECT hf_max INTO user_max_hr
|
||||
FROM profiles
|
||||
WHERE id = NEW.profile_id;
|
||||
|
||||
IF NEW.hr_avg IS NOT NULL AND user_max_hr IS NOT NULL AND user_max_hr > 0 THEN
|
||||
NEW.avg_hr_percent := (NEW.hr_avg::float / user_max_hr::float) * 100;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.kcal_active IS NOT NULL AND NEW.distance_km IS NOT NULL AND NEW.distance_km > 0 THEN
|
||||
IF NEW.kcal_per_km IS NULL THEN
|
||||
NEW.kcal_per_km := NEW.kcal_active::float / NEW.distance_km;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✓ Migration 056: kcal_per_km nur noch bei INSERT auto-abgeleitet';
|
||||
END $$;
|
||||
|
|
@ -3,8 +3,9 @@ Pydantic Models for Mitai Jinkendo API
|
|||
|
||||
Data validation schemas for request/response bodies.
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Profile Models ────────────────────────────────────────────────────────────
|
||||
|
|
@ -82,8 +83,17 @@ class ActivityEntry(BaseModel):
|
|||
kcal_resting: Optional[float] = None
|
||||
hr_avg: Optional[float] = None
|
||||
hr_max: Optional[float] = None
|
||||
hr_min: Optional[int] = None # DB-Spalte hr_min (Parameter min_hr)
|
||||
distance_km: Optional[float] = None
|
||||
rpe: Optional[int] = None
|
||||
pace_min_per_km: Optional[float] = None
|
||||
cadence: Optional[int] = None
|
||||
avg_power: Optional[int] = None
|
||||
elevation_gain: Optional[int] = None
|
||||
temperature_celsius: Optional[float] = None
|
||||
humidity_percent: Optional[int] = None
|
||||
avg_hr_percent: Optional[float] = None
|
||||
kcal_per_km: Optional[float] = None
|
||||
source: Optional[str] = 'manual'
|
||||
notes: Optional[str] = None
|
||||
training_type_id: Optional[int] = None # v9d: Training type categorization
|
||||
|
|
@ -91,6 +101,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
|
||||
|
|
|
|||
|
|
@ -127,16 +127,17 @@ def register_activity_group_1():
|
|||
activity_detail_metadata = PlaceholderMetadata(
|
||||
key="activity_detail",
|
||||
category="Aktivität",
|
||||
description="Detaillierte Liste der letzten 14 Tage Aktivität",
|
||||
description="Detaillierte Liste der letzten 14 Tage Aktivität (Kopfzeile + EAV-Metriken)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_format_activity_detail",
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=["activity_log", "training_types"],
|
||||
resolver_function="get_activity_detail",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_activity_detail_data",
|
||||
source_tables=["activity_log", "activity_session_metrics", "training_parameters"],
|
||||
semantic_contract=(
|
||||
"Liefert eine strukturierte Liste aller Trainingseinheiten der letzten 14 Tage. "
|
||||
"Jede Einheit: Datum, Trainingstyp, Dauer (Minuten), optional Notizen. "
|
||||
"Sortiert chronologisch absteigend (neueste zuerst)."
|
||||
"Liefert bis zu 50 Einheiten (neueste zuerst) der letzten 14 Tage über "
|
||||
"get_activity_detail_data: activity_log-Spalten plus "
|
||||
"enrich_sessions_with_metrics (activity_session_metrics / Profil-EAV). "
|
||||
"Formatter hängt nicht-leere EAV-Werte als „| EAV: key=value; …“ an."
|
||||
),
|
||||
business_meaning=(
|
||||
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
|
||||
|
|
@ -147,7 +148,9 @@ def register_activity_group_1():
|
|||
time_window="14d",
|
||||
output_type=OutputType.LIST,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="Liste von Strings, eine Zeile pro Einheit: 'YYYY-MM-DD: Typ (Dauer min)'",
|
||||
format_hint=(
|
||||
"Pro Zeile: Datum, Typ, Dauer, kcal, optional HF, optional „| EAV: …“ aus Session-Metriken"
|
||||
),
|
||||
example_output=(
|
||||
"2026-03-28: Krafttraining (45 min)\\n"
|
||||
"2026-03-27: Laufen (30 min)\\n"
|
||||
|
|
@ -163,19 +166,15 @@ def register_activity_group_1():
|
|||
legacy_display="Keine Aktivitätsdaten"
|
||||
),
|
||||
known_limitations=(
|
||||
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
|
||||
"Formatierung direkt im Resolver. "
|
||||
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten (z.B. 0 min) "
|
||||
"werden gelistet. JOIN mit training_types für Typ-Namen."
|
||||
"Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output "
|
||||
"(Hard-Limit Resolver). Doppelte Spalten (z.B. duration_min in Kopf und EAV) können "
|
||||
"in EAV wiederholt erscheinen — KI kann dominante Spalte nutzen."
|
||||
),
|
||||
layer_1_decision="NONE - Old resolver pattern (direct SQL in resolver)",
|
||||
layer_2a_decision="Placeholder Resolver (formatting + SQL query)",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment=(
|
||||
"NOT ALIGNED with Phase 0c Multi-Layer Architecture. "
|
||||
"Should be refactored to use data_layer function."
|
||||
),
|
||||
issue_53_alignment="NOT ALIGNED - no layer separation"
|
||||
layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
|
||||
layer_2a_decision="get_activity_detail (Formatierung)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c Layer 1 + EAV-Anreicherung",
|
||||
issue_53_alignment="Layer 1"
|
||||
)
|
||||
|
||||
activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -159,6 +161,7 @@ def register_activity_session_insights():
|
|||
),
|
||||
known_limitations=(
|
||||
"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')",
|
||||
|
|
|
|||
|
|
@ -419,14 +419,21 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str:
|
|||
|
||||
# Format as readable list (max 20 entries to avoid token bloat)
|
||||
lines = []
|
||||
for activity in data['activities'][:20]:
|
||||
hr_str = f" HF={activity['hr_avg']}" if activity['hr_avg'] else ""
|
||||
for activity in data["activities"][:20]:
|
||||
hr_str = f", HF={activity['hr_avg']}" if activity.get("hr_avg") else ""
|
||||
eav_parts = []
|
||||
for m in activity.get("session_metrics") or []:
|
||||
k, v = m.get("key"), m.get("value")
|
||||
if k is None or v is None:
|
||||
continue
|
||||
eav_parts.append(f"{k}={v}")
|
||||
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
|
||||
lines.append(
|
||||
f"{activity['date']}: {activity['activity_type']} "
|
||||
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str})"
|
||||
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str}{eav_str})"
|
||||
)
|
||||
|
||||
return '\n'.join(lines)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
||||
|
|
|
|||
|
|
@ -7,16 +7,69 @@ import csv
|
|||
import io
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
import calendar
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
|
||||
|
||||
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
|
||||
from data_layer.activity_persistence_orchestrator import (
|
||||
get_mappable_activity_field_catalog,
|
||||
insert_activity_from_entry,
|
||||
run_activity_post_write_hooks,
|
||||
update_activity_from_entry,
|
||||
find_activity_duplicate_id,
|
||||
update_activity_columns,
|
||||
insert_activity_csv_minimal,
|
||||
run_activity_post_write_hooks_import,
|
||||
new_activity_id,
|
||||
)
|
||||
from data_layer.activity_time_normalize import normalize_activity_start
|
||||
|
||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
|
||||
|
||||
|
||||
def _month_date_bounds(ym: str) -> tuple[date, date]:
|
||||
m = _MONTH_RE.match((ym or "").strip())
|
||||
if not m:
|
||||
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein")
|
||||
y, mo = int(m.group(1)), int(m.group(2))
|
||||
if mo < 1 or mo > 12:
|
||||
raise HTTPException(status_code=400, detail="Ungültiger Monat")
|
||||
last = calendar.monthrange(y, mo)[1]
|
||||
return date(y, mo, 1), date(y, mo, last)
|
||||
|
||||
|
||||
_ACTIVITY_DEDUP_WINDOW = """
|
||||
PARTITION BY al.profile_id, al.date,
|
||||
COALESCE(al.activity_type, ''),
|
||||
COALESCE(al.start_time::text, ''),
|
||||
COALESCE(ROUND(al.duration_min::numeric, 1), '-999999'::numeric),
|
||||
COALESCE(ROUND(al.kcal_active::numeric, 1), '-999999'::numeric)
|
||||
ORDER BY al.created DESC NULLS LAST, al.id DESC
|
||||
"""
|
||||
|
||||
|
||||
def _activity_rows_after_list_query(cur):
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
d = r2d(r)
|
||||
if not d:
|
||||
continue
|
||||
d.pop("_dup_rn", None)
|
||||
rows.append(d)
|
||||
return rows
|
||||
|
||||
|
||||
# Evaluation import with error handling (Phase 1.2)
|
||||
try:
|
||||
|
|
@ -27,49 +80,139 @@ except Exception as e:
|
|||
EVALUATION_AVAILABLE = False
|
||||
evaluate_and_save_activity = None
|
||||
|
||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_activity(
|
||||
limit: int = Query(200, ge=1, le=50_000),
|
||||
offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"),
|
||||
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"),
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
month: Optional[str] = Query(
|
||||
None,
|
||||
description='Kalendermonat "YYYY-MM" (ganzer Monat; schließt days und offset aus)',
|
||||
),
|
||||
skip_quality_filter: bool = Query(
|
||||
False,
|
||||
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.",
|
||||
),
|
||||
collapse_duplicate_sessions: bool = Query(
|
||||
False,
|
||||
description="True = Sessions mit gleichem Datum/Typ/Startzeit/Dauer/Kcal falten (neueste Zeile behalten).",
|
||||
),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
# Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet).
|
||||
pid = str(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
||||
# Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter)
|
||||
if skip_quality_filter:
|
||||
quality_filter = ""
|
||||
quality_filter_al = ""
|
||||
else:
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
profile = r2d(cur.fetchone())
|
||||
quality_filter = get_quality_filter_sql(profile)
|
||||
quality_filter = get_quality_filter_sql(profile or {}, "")
|
||||
quality_filter_al = get_quality_filter_sql(profile or {}, "al.")
|
||||
|
||||
if month:
|
||||
if days is not None:
|
||||
raise HTTPException(status_code=400, detail="month und days schließen sich aus")
|
||||
if offset != 0:
|
||||
raise HTTPException(status_code=400, detail="month und offset schließen sich aus")
|
||||
d0, d1 = _month_date_bounds(month)
|
||||
if collapse_duplicate_sessions:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT d.* FROM (
|
||||
SELECT al.*, ROW_NUMBER() OVER (
|
||||
{_ACTIVITY_DEDUP_WINDOW}
|
||||
) AS _dup_rn
|
||||
FROM activity_log al
|
||||
WHERE al.profile_id = %s
|
||||
{quality_filter_al}
|
||||
AND al.date >= %s AND al.date <= %s
|
||||
) d
|
||||
WHERE d._dup_rn = 1
|
||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(pid, d0, d1, limit),
|
||||
)
|
||||
return _activity_rows_after_list_query(cur)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
AND date >= %s AND date <= %s
|
||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(pid, d0, d1, limit),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
if days is not None:
|
||||
if collapse_duplicate_sessions:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT d.* FROM (
|
||||
SELECT al.*, ROW_NUMBER() OVER (
|
||||
{_ACTIVITY_DEDUP_WINDOW}
|
||||
) AS _dup_rn
|
||||
FROM activity_log al
|
||||
WHERE al.profile_id = %s
|
||||
{quality_filter_al}
|
||||
AND al.date >= (CURRENT_DATE - %s::integer)
|
||||
) d
|
||||
WHERE d._dup_rn = 1
|
||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(pid, days, limit, offset),
|
||||
)
|
||||
return _activity_rows_after_list_query(cur)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
AND date >= (CURRENT_DATE - %s::integer)
|
||||
ORDER BY date DESC, start_time DESC
|
||||
LIMIT %s
|
||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(pid, days, limit),
|
||||
(pid, days, limit, offset),
|
||||
)
|
||||
else:
|
||||
if collapse_duplicate_sessions:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT d.* FROM (
|
||||
SELECT al.*, ROW_NUMBER() OVER (
|
||||
{_ACTIVITY_DEDUP_WINDOW}
|
||||
) AS _dup_rn
|
||||
FROM activity_log al
|
||||
WHERE al.profile_id = %s
|
||||
{quality_filter_al}
|
||||
) d
|
||||
WHERE d._dup_rn = 1
|
||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(pid, limit, offset),
|
||||
)
|
||||
return _activity_rows_after_list_query(cur)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
ORDER BY date DESC, start_time DESC
|
||||
LIMIT %s
|
||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(pid, limit),
|
||||
(pid, limit, offset),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
|
@ -95,37 +238,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
|||
)
|
||||
|
||||
eid = str(uuid.uuid4())
|
||||
d = e.model_dump()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""INSERT INTO activity_log
|
||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||
hr_avg,hr_max,distance_km,rpe,source,notes,created)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
|
||||
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
||||
d['rpe'],d['source'],d['notes']))
|
||||
|
||||
# Phase 1.2: Auto-evaluation after INSERT
|
||||
if EVALUATION_AVAILABLE:
|
||||
# Load the activity data to evaluate
|
||||
cur.execute("""
|
||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
||||
rpe, pace_min_per_km, cadence, elevation_gain
|
||||
FROM activity_log
|
||||
WHERE id = %s
|
||||
""", (eid,))
|
||||
activity_row = cur.fetchone()
|
||||
if activity_row:
|
||||
activity_dict = dict(activity_row)
|
||||
training_type_id = activity_dict.get("training_type_id")
|
||||
if training_type_id:
|
||||
try:
|
||||
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
|
||||
logger.info(f"[AUTO-EVAL] Evaluated activity {eid} on INSERT")
|
||||
except Exception as eval_error:
|
||||
logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}")
|
||||
insert_activity_from_entry(cur, pid, eid, e)
|
||||
run_activity_post_write_hooks(cur, pid, eid)
|
||||
|
||||
# Phase 2: Increment usage counter (always for new entries)
|
||||
increment_feature_usage(pid, 'activity_entries')
|
||||
|
|
@ -133,36 +249,127 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
|||
return {"id":eid,"date":e.date}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def activity_stats(
|
||||
skip_quality_filter: bool = Query(
|
||||
False,
|
||||
description="True = Statistik-Kacheln ohne Profil-Qualitätsfilter (passend zur /activity-Liste).",
|
||||
),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Get activity statistics (last 30 entries)."""
|
||||
pid = str(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if skip_quality_filter:
|
||||
quality_filter = ""
|
||||
else:
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
profile = r2d(cur.fetchone())
|
||||
quality_filter = get_quality_filter_sql(profile or {}, "")
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*)::bigint AS c FROM activity_log WHERE profile_id=%s {quality_filter}",
|
||||
(pid,),
|
||||
)
|
||||
total_in_profile = int(cur.fetchone()["c"])
|
||||
if skip_quality_filter:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT d.* FROM (
|
||||
SELECT al.*, ROW_NUMBER() OVER (
|
||||
{_ACTIVITY_DEDUP_WINDOW}
|
||||
) AS _dup_rn
|
||||
FROM activity_log al
|
||||
WHERE al.profile_id = %s
|
||||
) d
|
||||
WHERE d._dup_rn = 1
|
||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
rows = _activity_rows_after_list_query(cur)
|
||||
else:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s {quality_filter}
|
||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
if not rows:
|
||||
return {
|
||||
"count": 0,
|
||||
"sample_size": 0,
|
||||
"total_in_profile": total_in_profile,
|
||||
"total_kcal": 0,
|
||||
"total_min": 0,
|
||||
"by_type": {},
|
||||
}
|
||||
total_kcal = sum(float(r.get("kcal_active") or 0) for r in rows)
|
||||
total_min = sum(float(r.get("duration_min") or 0) for r in rows)
|
||||
by_type = {}
|
||||
for r in rows:
|
||||
t = r["activity_type"]
|
||||
by_type.setdefault(t, {"count": 0, "kcal": 0, "min": 0})
|
||||
by_type[t]["count"] += 1
|
||||
by_type[t]["kcal"] += float(r.get("kcal_active") or 0)
|
||||
by_type[t]["min"] += float(r.get("duration_min") or 0)
|
||||
return {
|
||||
"count": len(rows),
|
||||
"sample_size": len(rows),
|
||||
"total_in_profile": total_in_profile,
|
||||
"total_kcal": round(total_kcal),
|
||||
"total_min": round(total_min),
|
||||
"by_type": by_type,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/uncategorized")
|
||||
def list_uncategorized_activities(
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Get activities without assigned training type, grouped by activity_type."""
|
||||
pid = str(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT activity_type, COUNT(*) as count,
|
||||
MIN(date) as first_date, MAX(date) as last_date
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND training_type_id IS NULL
|
||||
GROUP BY activity_type
|
||||
ORDER BY count DESC
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/mappable-fields")
|
||||
def get_activity_mappable_fields(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Vollständiger Katalog für Import-Mappings (activity_log-Kernfelder + alle aktiven training_parameters).
|
||||
Werte für Keys ohne Schema zur konkreten Session werden beim Import ignoriert.
|
||||
"""
|
||||
pid = str(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return get_mappable_activity_field_catalog(cur, pid)
|
||||
|
||||
|
||||
@router.put("/{eid}")
|
||||
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Update existing activity entry."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
d = e.model_dump()
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
||||
list(d.values())+[eid,pid])
|
||||
|
||||
# Phase 1.2: Auto-evaluation after UPDATE
|
||||
if EVALUATION_AVAILABLE:
|
||||
# Load the updated activity data to evaluate
|
||||
cur.execute("""
|
||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
||||
rpe, pace_min_per_km, cadence, elevation_gain
|
||||
FROM activity_log
|
||||
WHERE id = %s
|
||||
""", (eid,))
|
||||
activity_row = cur.fetchone()
|
||||
if activity_row:
|
||||
activity_dict = dict(activity_row)
|
||||
training_type_id = activity_dict.get("training_type_id")
|
||||
if training_type_id:
|
||||
try:
|
||||
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
|
||||
logger.info(f"[AUTO-EVAL] Re-evaluated activity {eid} on UPDATE")
|
||||
except Exception as eval_error:
|
||||
logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}")
|
||||
update_activity_from_entry(cur, pid, eid, e)
|
||||
run_activity_post_write_hooks(cur, pid, eid)
|
||||
|
||||
return {"id":eid}
|
||||
|
||||
|
|
@ -177,25 +384,53 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None),
|
|||
return {"ok":True}
|
||||
|
||||
|
||||
@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)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
@router.put("/{eid}/metrics")
|
||||
def replace_activity_metrics(
|
||||
eid: str,
|
||||
body: ActivityMetricsReplace,
|
||||
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 = str(session["profile_id"])
|
||||
payload = [m.model_dump() for m in body.metrics]
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
|
||||
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
|
||||
total_min=sum(float(r.get('duration_min') or 0) for r in rows)
|
||||
by_type={}
|
||||
for r in rows:
|
||||
t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
|
||||
by_type[t]['count']+=1
|
||||
by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
|
||||
by_type[t]['min']+=float(r.get('duration_min') or 0)
|
||||
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
||||
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,
|
||||
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 = str(session["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
|
||||
|
||||
|
||||
def get_training_type_for_activity_with_cursor(cur, activity_type: str, profile_id: str | None = None):
|
||||
|
|
@ -251,23 +486,6 @@ def get_training_type_for_activity(activity_type: str, profile_id: str = None):
|
|||
return get_training_type_for_activity_with_cursor(cur, activity_type, profile_id)
|
||||
|
||||
|
||||
@router.get("/uncategorized")
|
||||
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get activities without assigned training type, grouped by activity_type."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT activity_type, COUNT(*) as count,
|
||||
MIN(date) as first_date, MAX(date) as last_date
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND training_type_id IS NULL
|
||||
GROUP BY activity_type
|
||||
ORDER BY count DESC
|
||||
""", (pid,))
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/bulk-categorize")
|
||||
def bulk_categorize_activities(
|
||||
data: dict,
|
||||
|
|
@ -353,7 +571,10 @@ def bulk_categorize_activities(
|
|||
|
||||
@router.post("/import-csv")
|
||||
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Import Apple Health workout CSV with automatic training type mapping."""
|
||||
"""
|
||||
Legacy-Upload (Apple Health Workout-CSV-Spaltennamen).
|
||||
Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV.
|
||||
"""
|
||||
pid = get_pid(x_profile_id)
|
||||
raw = await file.read()
|
||||
try: text = raw.decode('utf-8')
|
||||
|
|
@ -367,9 +588,11 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
|||
for row in reader:
|
||||
wtype = row.get('Workout Type','').strip()
|
||||
start = row.get('Start','').strip()
|
||||
if not wtype or not start: continue
|
||||
try: date = start[:10]
|
||||
except: continue
|
||||
if not wtype or not start:
|
||||
continue
|
||||
workout_date, workout_start_t = normalize_activity_start(start)
|
||||
if not workout_date:
|
||||
continue
|
||||
dur = row.get('Duration','').strip()
|
||||
duration_min = None
|
||||
if dur:
|
||||
|
|
@ -386,106 +609,82 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
|||
# Map activity_type to training_type_id using database mappings
|
||||
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
|
||||
|
||||
kcal_a = kj(row.get("Aktive Energie (kJ)", ""))
|
||||
kcal_r = kj(row.get("Ruheeinträge (kJ)", ""))
|
||||
hr_av = tf(row.get("Durchschn. Herzfrequenz (count/min)", ""))
|
||||
hr_mx = tf(row.get("Max. Herzfrequenz (count/min)", ""))
|
||||
dist_km = tf(row.get("Distanz (km)", ""))
|
||||
try:
|
||||
# Check if entry already exists (duplicate detection by date + start_time)
|
||||
cur.execute("""
|
||||
SELECT id FROM activity_log
|
||||
WHERE profile_id = %s AND date = %s AND start_time = %s
|
||||
""", (pid, date, start))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
# Update existing entry (e.g., to add training type mapping)
|
||||
existing_id = existing['id']
|
||||
cur.execute("""
|
||||
UPDATE activity_log
|
||||
SET end_time = %s,
|
||||
activity_type = %s,
|
||||
duration_min = %s,
|
||||
kcal_active = %s,
|
||||
kcal_resting = %s,
|
||||
hr_avg = %s,
|
||||
hr_max = %s,
|
||||
distance_km = %s,
|
||||
training_type_id = %s,
|
||||
training_category = %s,
|
||||
training_subcategory = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
row.get('End',''), wtype, duration_min,
|
||||
kj(row.get('Aktive Energie (kJ)','')),
|
||||
kj(row.get('Ruheeinträge (kJ)','')),
|
||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Distanz (km)','')),
|
||||
training_type_id, training_category, training_subcategory,
|
||||
existing_id
|
||||
))
|
||||
skipped += 1 # Count as skipped (not newly inserted)
|
||||
|
||||
# Phase 1.2: Auto-evaluation after CSV import UPDATE
|
||||
if EVALUATION_AVAILABLE and training_type_id:
|
||||
try:
|
||||
# Build activity dict for evaluation
|
||||
activity_dict = {
|
||||
"id": existing_id,
|
||||
"profile_id": pid,
|
||||
"date": date,
|
||||
"training_type_id": training_type_id,
|
||||
existing_id = find_activity_duplicate_id(cur, pid, workout_date, workout_start_t)
|
||||
if existing_id:
|
||||
update_activity_columns(
|
||||
cur,
|
||||
pid,
|
||||
str(existing_id),
|
||||
{
|
||||
"start_time": workout_start_t,
|
||||
"end_time": row.get("End", "") or None,
|
||||
"activity_type": wtype,
|
||||
"duration_min": duration_min,
|
||||
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
"distance_km": tf(row.get('Distanz (km)','')),
|
||||
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
|
||||
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
|
||||
"rpe": None,
|
||||
"pace_min_per_km": None,
|
||||
"cadence": None,
|
||||
"elevation_gain": None
|
||||
}
|
||||
evaluate_and_save_activity(cur, existing_id, activity_dict, training_type_id, pid)
|
||||
logger.debug(f"[AUTO-EVAL] Re-evaluated updated activity {existing_id}")
|
||||
except Exception as eval_error:
|
||||
logger.warning(f"[AUTO-EVAL] Failed to re-evaluate updated activity {existing_id}: {eval_error}")
|
||||
"kcal_active": kcal_a,
|
||||
"kcal_resting": kcal_r,
|
||||
"hr_avg": hr_av,
|
||||
"hr_max": hr_mx,
|
||||
"distance_km": dist_km,
|
||||
"training_type_id": training_type_id,
|
||||
"training_category": training_category,
|
||||
"training_subcategory": training_subcategory,
|
||||
},
|
||||
)
|
||||
skipped += 1
|
||||
run_activity_post_write_hooks_import(
|
||||
cur,
|
||||
pid,
|
||||
str(existing_id),
|
||||
workout_date=workout_date,
|
||||
training_type_id=training_type_id,
|
||||
duration_min=duration_min,
|
||||
hr_avg=hr_av,
|
||||
hr_max=hr_mx,
|
||||
distance_km=dist_km,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
)
|
||||
else:
|
||||
# Insert new entry
|
||||
new_id = str(uuid.uuid4())
|
||||
cur.execute("""INSERT INTO activity_log
|
||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||
(new_id,pid,date,start,row.get('End',''),wtype,duration_min,
|
||||
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Distanz (km)','')),
|
||||
training_type_id,training_category,training_subcategory))
|
||||
new_id = new_activity_id()
|
||||
insert_activity_csv_minimal(
|
||||
cur,
|
||||
pid,
|
||||
new_id,
|
||||
date_iso=workout_date,
|
||||
start_time=workout_start_t,
|
||||
end_time=row.get("End", "") or None,
|
||||
activity_type=wtype,
|
||||
duration_min=duration_min,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
hr_avg=hr_av,
|
||||
hr_max=hr_mx,
|
||||
distance_km=dist_km,
|
||||
training_type_id=training_type_id,
|
||||
training_category=training_category,
|
||||
training_subcategory=training_subcategory,
|
||||
source="apple_health",
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
# Phase 1.2: Auto-evaluation after CSV import INSERT
|
||||
if EVALUATION_AVAILABLE and training_type_id:
|
||||
try:
|
||||
# Build activity dict for evaluation
|
||||
activity_dict = {
|
||||
"id": new_id,
|
||||
"profile_id": pid,
|
||||
"date": date,
|
||||
"training_type_id": training_type_id,
|
||||
"duration_min": duration_min,
|
||||
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
"distance_km": tf(row.get('Distanz (km)','')),
|
||||
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
|
||||
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
|
||||
"rpe": None,
|
||||
"pace_min_per_km": None,
|
||||
"cadence": None,
|
||||
"elevation_gain": None
|
||||
}
|
||||
evaluate_and_save_activity(cur, new_id, activity_dict, training_type_id, pid)
|
||||
logger.debug(f"[AUTO-EVAL] Evaluated imported activity {new_id}")
|
||||
except Exception as eval_error:
|
||||
logger.warning(f"[AUTO-EVAL] Failed to evaluate imported activity {new_id}: {eval_error}")
|
||||
run_activity_post_write_hooks_import(
|
||||
cur,
|
||||
pid,
|
||||
new_id,
|
||||
workout_date=workout_date,
|
||||
training_type_id=training_type_id,
|
||||
duration_min=duration_min,
|
||||
hr_avg=hr_av,
|
||||
hr_max=hr_mx,
|
||||
distance_km=dist_km,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Import row failed: {e}")
|
||||
skipped+=1
|
||||
|
|
|
|||
266
backend/routers/admin_activity_attribute_profiles.py
Normal file
266
backend/routers/admin_activity_attribute_profiles.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""
|
||||
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)
|
||||
|
||||
|
||||
class CategoryParameterUpdate(BaseModel):
|
||||
sort_order: Optional[int] = None
|
||||
required: Optional[bool] = None
|
||||
ui_group: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class TypeParameterUpdate(BaseModel):
|
||||
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.put("/training-category-parameters/{link_id}")
|
||||
def admin_update_category_parameter(
|
||||
link_id: int,
|
||||
body: CategoryParameterUpdate,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
if not patch:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
cols: list[str] = []
|
||||
vals: list = []
|
||||
if "sort_order" in patch:
|
||||
cols.append("sort_order = %s")
|
||||
vals.append(patch["sort_order"])
|
||||
if "required" in patch:
|
||||
cols.append("required = %s")
|
||||
vals.append(patch["required"])
|
||||
if "ui_group" in patch:
|
||||
cols.append("ui_group = %s")
|
||||
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
|
||||
if not cols:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
vals.append(link_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
f"UPDATE training_category_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
||||
vals,
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": link_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.put("/training-type-parameters/{link_id}")
|
||||
def admin_update_type_parameter(
|
||||
link_id: int,
|
||||
body: TypeParameterUpdate,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
if not patch:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
cols: list[str] = []
|
||||
vals: list = []
|
||||
if "sort_order" in patch:
|
||||
cols.append("sort_order = %s")
|
||||
vals.append(patch["sort_order"])
|
||||
if "required" in patch:
|
||||
cols.append("required = %s")
|
||||
vals.append(patch["required"])
|
||||
if "ui_group" in patch:
|
||||
cols.append("ui_group = %s")
|
||||
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
|
||||
if not cols:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
vals.append(link_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
f"UPDATE training_type_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
||||
vals,
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": link_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}
|
||||
|
|
@ -25,6 +25,7 @@ from csv_parser.import_row_processing import (
|
|||
)
|
||||
from csv_parser.module_registry import get_module_definition
|
||||
from csv_parser.template_validator import validate_csv_template
|
||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
||||
|
||||
router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"])
|
||||
|
||||
|
|
@ -181,6 +182,8 @@ async def admin_analyze_csv_for_template(
|
|||
sig = column_signature(headers)
|
||||
|
||||
seed_row: dict | None = None
|
||||
field_mappings: dict = {}
|
||||
type_conversions: dict = {}
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if seed_template_id is not None:
|
||||
|
|
@ -215,6 +218,11 @@ async def admin_analyze_csv_for_template(
|
|||
if best and best_key[0] > 0:
|
||||
seed_row = best
|
||||
|
||||
mod_def = get_module_definition(module) or {}
|
||||
eff_fields = dict(mod_def.get("fields") or {})
|
||||
if module == "activity":
|
||||
eff_fields = merge_activity_csv_module_fields(cur, eff_fields)
|
||||
|
||||
seed_fm = (seed_row or {}).get("field_mappings") or {}
|
||||
if isinstance(seed_fm, str):
|
||||
seed_fm = {}
|
||||
|
|
@ -222,8 +230,18 @@ async def admin_analyze_csv_for_template(
|
|||
if not isinstance(seed_tc, dict):
|
||||
seed_tc = {}
|
||||
|
||||
field_mappings = suggest_field_mappings(headers, module, seed_fm if seed_fm else None)
|
||||
type_conversions = build_type_conversions_for_mapping(module, field_mappings, seed_tc if seed_tc else None)
|
||||
field_mappings = suggest_field_mappings(
|
||||
headers,
|
||||
module,
|
||||
seed_fm if seed_fm else None,
|
||||
effective_fields=eff_fields,
|
||||
)
|
||||
type_conversions = build_type_conversions_for_mapping(
|
||||
module,
|
||||
field_mappings,
|
||||
seed_tc if seed_tc else None,
|
||||
effective_fields=eff_fields,
|
||||
)
|
||||
|
||||
seed_meta = None
|
||||
if seed_row:
|
||||
|
|
@ -270,12 +288,15 @@ def validate_system_template_dry_run(body: CsvTemplateValidateBody, session: dic
|
|||
"""
|
||||
if not get_module_definition(body.module):
|
||||
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return validate_csv_template(
|
||||
body.module,
|
||||
body.field_mappings,
|
||||
body.type_conversions,
|
||||
body.import_row_processing,
|
||||
body.column_signature,
|
||||
cur=cur,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -297,18 +318,19 @@ def get_system_template(template_id: int, session: dict = Depends(require_admin)
|
|||
def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)):
|
||||
if not get_module_definition(body.module):
|
||||
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
report = validate_csv_template(
|
||||
body.module,
|
||||
body.field_mappings,
|
||||
body.type_conversions,
|
||||
body.import_row_processing,
|
||||
body.column_signature,
|
||||
cur=cur,
|
||||
)
|
||||
if not report["valid"]:
|
||||
raise HTTPException(status_code=422, detail=report)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO csv_field_mappings (
|
||||
|
|
@ -367,6 +389,7 @@ def update_system_template(
|
|||
tc_eff,
|
||||
irp_eff,
|
||||
col_eff if isinstance(col_eff, list) else None,
|
||||
cur=cur,
|
||||
)
|
||||
if not report["valid"]:
|
||||
raise HTTPException(status_code=422, detail=report)
|
||||
|
|
|
|||
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}
|
||||
|
|
@ -34,6 +34,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp
|
|||
from csv_parser.field_units import source_unit_choices_for_field
|
||||
from csv_parser.import_errors import enrich_row_error
|
||||
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
|
||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
||||
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
|
||||
|
||||
router = APIRouter(prefix="/api/csv", tags=["csv-import"])
|
||||
|
|
@ -66,11 +67,16 @@ def _mapping_to_summary(m: dict) -> dict:
|
|||
def csv_modules(session: dict = Depends(require_auth)):
|
||||
"""Unterstützte Import-Module und Felddefinitionen."""
|
||||
out = []
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for mid in list_modules():
|
||||
d = get_module_definition(mid)
|
||||
if d:
|
||||
field_src = dict(d.get("fields") or {})
|
||||
if mid == "activity":
|
||||
field_src = merge_activity_csv_module_fields(cur, field_src)
|
||||
fields_out = {}
|
||||
for fname, finfo in (d.get("fields") or {}).items():
|
||||
for fname, finfo in field_src.items():
|
||||
fd = dict(finfo)
|
||||
opts = source_unit_choices_for_field(mid, fname)
|
||||
if opts:
|
||||
|
|
@ -257,6 +263,10 @@ async def analyze_csv(
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if module == "activity" and mod_def:
|
||||
available_fields = merge_activity_csv_module_fields(
|
||||
cur, dict(mod_def.get("fields") or {})
|
||||
)
|
||||
if module:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
|
|||
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"] == []
|
||||
|
|
@ -249,11 +249,18 @@ def test_run_universal_import_activity_garmin_time_plus_date_columns(monkeypatch
|
|||
cur = _SeqCursor([None, {"id": new_id}])
|
||||
out = run_universal_csv_import(cur, PID, "activity", text, "garmin.csv", mapping)
|
||||
assert out["rows_imported"] == 1
|
||||
# Duplicate-Key muss Datum + kombinierte Startzeit enthalten
|
||||
assert any(
|
||||
params and "2024-01-20 08:30:00" in str(params)
|
||||
# Duplicate-Check: Datum + TIME, IS NOT DISTINCT FROM (kein String-Vergleich für start_time)
|
||||
dup_sqls = [
|
||||
(_sql, params)
|
||||
for _sql, params in cur.executes
|
||||
if params
|
||||
if params and "IS NOT DISTINCT FROM" in _sql and "activity_log" in _sql
|
||||
]
|
||||
assert dup_sqls, f"erwarteter Duplicate-SELECT fehlt; executes={cur.executes!r}"
|
||||
assert any(
|
||||
len(p) >= 3
|
||||
and str(p[1]).startswith("2024-01-20")
|
||||
and (getattr(p[2], "hour", None) == 8 and getattr(p[2], "minute", None) == 30)
|
||||
for _, p in dup_sqls
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,29 @@ def test_parse_csv_sample_header():
|
|||
assert rows[0]["kcal"] == "2000"
|
||||
|
||||
|
||||
def test_parse_csv_sample_nbsp_in_header_matches_normal_space_key():
|
||||
"""Excel/Apple: NBSP (U+00A0) im Spaltennamen — gleicher Key wie normales Leerzeichen."""
|
||||
text = "Aufgestiegene\u00a0Höhe (m);Wert\n12;3\n"
|
||||
headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3)
|
||||
assert headers == ["Aufgestiegene Höhe (m)", "Wert"]
|
||||
assert rows[0]["Aufgestiegene Höhe (m)"] == "12"
|
||||
|
||||
|
||||
def test_iter_csv_dict_rows_nbsp_header_canonical():
|
||||
text = "col\u00a0one;b\n1;2\n"
|
||||
rows = list(iter_csv_dict_rows(text, ";", has_header=True))
|
||||
assert rows == [{"col one": "1", "b": "2"}]
|
||||
|
||||
|
||||
def test_build_row_field_mapping_space_vs_nbsp_in_csv_header():
|
||||
"""Vorlage (Dialog) mit normalem Leerzeichen, CSV mit NBSP — Zuordnung muss greifen."""
|
||||
csv_row = {"Aufgestiegene\u00a0Höhe (m)": "10"}
|
||||
fm = {"Aufgestiegene Höhe (m)": "stola"}
|
||||
tc = {"stola": {"type": "float", "decimal_separator": ".", "flexible": True}}
|
||||
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
|
||||
assert out.get("stola") == 10.0
|
||||
|
||||
|
||||
def test_column_signature_sorted_unique():
|
||||
sig = column_signature(["B", "a", "a"])
|
||||
assert sig == ["a", "b"]
|
||||
|
|
@ -183,6 +206,16 @@ def test_build_row_fddb_raw_header_keys_match_normalized_template():
|
|||
assert out["kcal"] is not None and abs(float(out["kcal"]) - (42000 / 4.184)) < 0.1
|
||||
|
||||
|
||||
def test_build_row_apple_workout_elevation_header_prefix_matches_shorter_mapping_key():
|
||||
"""Apple Workouts: „Aufgestiegene Höhe (m)“ normalisiert anders als manuell „aufgestiegene Höhe“."""
|
||||
csv_row = {"Aufgestiegene Höhe (m)": "47.13", "Workout Type": "Outdoor Spaziergang"}
|
||||
fm = {"aufgestiegene Höhe": "stola", "Workout Type": "activity_type"}
|
||||
tc = {"stola": {"type": "string"}}
|
||||
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
|
||||
assert out.get("stola") == "47.13"
|
||||
assert out.get("activity_type") == "Outdoor Spaziergang"
|
||||
|
||||
|
||||
def test_convert_date_ddmm_with_seconds():
|
||||
d = convert_value(
|
||||
"15.01.2024 14:30:00",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage'
|
|||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||
import AdminActivityAttributeProfilesPage from './pages/AdminActivityAttributeProfilesPage'
|
||||
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||
|
|
@ -255,6 +256,7 @@ function AppShell() {
|
|||
<Route path="widget-features" element={<AdminWidgetFeatureAssignmentsPage />} />
|
||||
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||
<Route path="activity-attribute-profiles" element={<AdminActivityAttributeProfilesPage />} />
|
||||
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ export const ADMIN_GROUPS = [
|
|||
label: 'Trainings-Profile',
|
||||
description: 'Training-Type-Profile (#15).',
|
||||
},
|
||||
{
|
||||
to: '/admin/activity-attribute-profiles',
|
||||
label: 'Session-Metriken (EAV)',
|
||||
description: 'Messgrößen-Katalog und Zuordnung zu Kategorie / Trainingstyp.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, startTransition } from 'react'
|
||||
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
|
|
@ -9,15 +9,98 @@ import dayjs from 'dayjs'
|
|||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
||||
/** Erfassungsseite /activity: pro Kalendermonat laden (ohne Qualitätsfilter, siehe Backend). */
|
||||
const ACTIVITY_MONTH_FETCH_LIMIT = 25_000
|
||||
|
||||
function ymdMonth(d = dayjs()) {
|
||||
return d.format('YYYY-MM')
|
||||
}
|
||||
|
||||
function prevMonthYm(ym) {
|
||||
return dayjs(`${ym}-01`).subtract(1, 'month').format('YYYY-MM')
|
||||
}
|
||||
|
||||
function compareActivities(a, b) {
|
||||
const da = a.date || ''
|
||||
const db = b.date || ''
|
||||
if (da !== db) return db.localeCompare(da)
|
||||
const sa = String(a.start_time || '')
|
||||
const sb = String(b.start_time || '')
|
||||
if (sa !== sb) return sb.localeCompare(sa)
|
||||
return String(b.id || '').localeCompare(String(a.id || ''))
|
||||
}
|
||||
|
||||
function dedupeActivitiesById(rows) {
|
||||
const m = new Map()
|
||||
for (const r of rows) {
|
||||
if (r?.id) m.set(r.id, r)
|
||||
}
|
||||
return [...m.values()].sort(compareActivities)
|
||||
}
|
||||
|
||||
/** activity_log: Spalten start_time / end_time sind TIME (Uhrzeit zum Kalendertag date), nicht volles Timestamp. */
|
||||
function timeInputValueFromApi(t) {
|
||||
if (t == null || t === '') return ''
|
||||
const s = String(t)
|
||||
if (s.includes('T') && s.length >= 16) return s.slice(11, 16)
|
||||
const m = s.match(/^(\d{1,2}):(\d{2})/)
|
||||
if (!m) return ''
|
||||
return `${m[1].padStart(2, '0')}:${m[2]}`
|
||||
}
|
||||
|
||||
function timePayloadFromInput(v) {
|
||||
const s = v == null ? '' : String(v).trim()
|
||||
if (!s) return null
|
||||
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
|
||||
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
|
||||
return s
|
||||
}
|
||||
|
||||
function formatTimeForList(t) {
|
||||
const v = timeInputValueFromApi(t)
|
||||
return v || ''
|
||||
}
|
||||
|
||||
const ACTIVITY_TYPES = [
|
||||
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
|
||||
'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen',
|
||||
'Cardio Dance','Geist & Körper','Sonstiges'
|
||||
]
|
||||
|
||||
/** Spalten, die mit ActivityEntry / UPDATE activity_log geschrieben werden dürfen (Übergang: Profilfelder → Kopfzeile). */
|
||||
const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
|
||||
'date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'activity_type',
|
||||
'duration_min',
|
||||
'kcal_active',
|
||||
'kcal_resting',
|
||||
'hr_avg',
|
||||
'hr_max',
|
||||
'hr_min',
|
||||
'distance_km',
|
||||
'pace_min_per_km',
|
||||
'cadence',
|
||||
'avg_power',
|
||||
'elevation_gain',
|
||||
'temperature_celsius',
|
||||
'humidity_percent',
|
||||
'avg_hr_percent',
|
||||
'kcal_per_km',
|
||||
'rpe',
|
||||
'source',
|
||||
'notes',
|
||||
'training_type_id',
|
||||
'training_category',
|
||||
'training_subcategory',
|
||||
])
|
||||
|
||||
function empty() {
|
||||
return {
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
activity_type: 'Traditionelles Krafttraining',
|
||||
duration_min: '', kcal_active: '',
|
||||
hr_avg: '', hr_max: '', rpe: '', notes: '',
|
||||
|
|
@ -27,6 +110,126 @@ function empty() {
|
|||
}
|
||||
}
|
||||
|
||||
function buildMetricsPayload(schema, draft) {
|
||||
const out = []
|
||||
for (const s of schema) {
|
||||
const raw = draft[s.key]
|
||||
if (s.data_type === 'boolean') {
|
||||
if (raw === '' || raw === null || raw === undefined) {
|
||||
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
|
||||
out.push({ parameter_key: s.key, value: null })
|
||||
continue
|
||||
}
|
||||
out.push({ parameter_key: s.key, value: !!raw })
|
||||
continue
|
||||
}
|
||||
const rawStr = raw === null || raw === undefined ? '' : String(raw).trim()
|
||||
if (rawStr === '') {
|
||||
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
|
||||
out.push({ parameter_key: s.key, value: null })
|
||||
continue
|
||||
}
|
||||
let v
|
||||
if (s.data_type === 'integer') {
|
||||
v = parseInt(rawStr, 10)
|
||||
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
|
||||
} else if (s.data_type === 'float') {
|
||||
v = parseFloat(rawStr)
|
||||
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
|
||||
} else {
|
||||
v = rawStr
|
||||
}
|
||||
out.push({ parameter_key: s.key, value: v })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
||||
const schemaList = Array.isArray(schema) ? schema : []
|
||||
const metricRows = Array.isArray(metrics) ? metrics : []
|
||||
const schemaKeys = new Set(schemaList.map((s) => s.key))
|
||||
const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.key))
|
||||
|
||||
if (schemaList.length === 0 && orphanMetrics.length === 0) return null
|
||||
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
|
||||
return (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
||||
{schemaList.map((s) => (
|
||||
<div key={s.key} className="form-row">
|
||||
<label className="form-label">
|
||||
{s.name_de}
|
||||
{s.required ? ' *' : ''}
|
||||
{s.unit ? ` (${s.unit})` : ''}
|
||||
</label>
|
||||
{s.data_type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto', marginRight: 'auto' }}
|
||||
checked={!!values[s.key]}
|
||||
onChange={(e) => set(s.key, e.target.checked)}
|
||||
/>
|
||||
) : s.data_type === 'integer' || s.data_type === 'float' ? (
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
step={s.data_type === 'integer' ? 1 : 'any'}
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
))}
|
||||
{orphanMetrics.length > 0 && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
||||
Werte aus Import/älteren Daten, die zum <strong>aktuellen</strong> Trainingsprofil dieser Session (Kategorie/Typ
|
||||
in activity_log) nicht ins Schema passen — nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der
|
||||
Datenbank stehen.
|
||||
</div>
|
||||
{orphanMetrics.map((row) => {
|
||||
const disp =
|
||||
values[row.key] === null || values[row.key] === undefined || values[row.key] === ''
|
||||
? '—'
|
||||
: String(values[row.key])
|
||||
return (
|
||||
<div key={row.key} className="form-row">
|
||||
<label className="form-label">
|
||||
{row.key}
|
||||
{row.unit ? ` (${row.unit})` : ''}
|
||||
</label>
|
||||
{row.data_type === 'boolean' ? (
|
||||
<input type="checkbox" style={{ width: 'auto', marginRight: 'auto' }} checked={!!values[row.key]} readOnly disabled />
|
||||
) : (
|
||||
<div
|
||||
className="form-input"
|
||||
style={{
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'default',
|
||||
color: 'var(--text1)',
|
||||
}}
|
||||
>
|
||||
{disp}
|
||||
</div>
|
||||
)}
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||
function ImportPanel({ onImported }) {
|
||||
const fileRef = useRef()
|
||||
|
|
@ -85,7 +288,17 @@ function ImportPanel({ onImported }) {
|
|||
}
|
||||
|
||||
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
||||
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
||||
function EntryForm({
|
||||
form,
|
||||
setForm,
|
||||
onSave,
|
||||
onCancel,
|
||||
saveLabel = 'Speichern',
|
||||
saving = false,
|
||||
error = null,
|
||||
usage = null,
|
||||
formExtras = null,
|
||||
}) {
|
||||
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -94,6 +307,30 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
|
|||
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Start (Uhrzeit)</label>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
className="form-input"
|
||||
style={{ width: 'auto', minWidth: 140 }}
|
||||
value={timeInputValueFromApi(form.start_time)}
|
||||
onChange={(e) => set('start_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')}
|
||||
/>
|
||||
<span className="form-unit">zum Datum oben</span>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Ende (Uhrzeit)</label>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
className="form-input"
|
||||
style={{ width: 'auto', minWidth: 140 }}
|
||||
value={timeInputValueFromApi(form.end_time)}
|
||||
onChange={(e) => set('end_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')}
|
||||
/>
|
||||
<span className="form-unit">optional</span>
|
||||
</div>
|
||||
<div style={{marginBottom:12}}>
|
||||
<TrainingTypeSelect
|
||||
value={form.training_type_id}
|
||||
|
|
@ -144,6 +381,7 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
|
|||
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
{formExtras}
|
||||
{error && (
|
||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
|
||||
{error}
|
||||
|
|
@ -181,12 +419,76 @@ export default function ActivityPage() {
|
|||
const [error, setError] = useState(null)
|
||||
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
||||
const [categories, setCategories] = useState({}) // v9d: Training categories
|
||||
const [sessionDetail, setSessionDetail] = useState(null)
|
||||
const [metricDraft, setMetricDraft] = useState({})
|
||||
const [sessionLoadError, setSessionLoadError] = useState(null)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [listLoadingMore, setListLoadingMore] = useState(false)
|
||||
const [selectedMonth, setSelectedMonth] = useState(() => ymdMonth())
|
||||
const [monthsIncluded, setMonthsIncluded] = useState(() => [ymdMonth()])
|
||||
const monthsIncludedRef = useRef(monthsIncluded)
|
||||
|
||||
const load = async () => {
|
||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||
setEntries(e); setStats(s)
|
||||
useEffect(() => {
|
||||
monthsIncludedRef.current = monthsIncluded
|
||||
}, [monthsIncluded])
|
||||
|
||||
const fetchMonthsChain = useCallback(async (chain) => {
|
||||
const lists = await Promise.all(
|
||||
chain.map((ym) =>
|
||||
api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
|
||||
skipQualityFilter: true,
|
||||
collapseDuplicateSessions: true,
|
||||
month: ym,
|
||||
})
|
||||
)
|
||||
)
|
||||
const merged = dedupeActivitiesById(lists.flat())
|
||||
const s = await api.activityStats({ skipQualityFilter: true })
|
||||
setEntries(merged)
|
||||
setStats(s)
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
await fetchMonthsChain(monthsIncludedRef.current)
|
||||
}, [fetchMonthsChain])
|
||||
|
||||
const onMonthPickerChange = (e) => {
|
||||
const ym = e.target.value
|
||||
if (!ym) return
|
||||
setSelectedMonth(ym)
|
||||
const chain = [ym]
|
||||
monthsIncludedRef.current = chain
|
||||
setMonthsIncluded(chain)
|
||||
void fetchMonthsChain(chain)
|
||||
}
|
||||
|
||||
const loadPreviousMonth = async () => {
|
||||
const chain = monthsIncludedRef.current
|
||||
if (chain.length === 0) return
|
||||
const oldest = chain[chain.length - 1]
|
||||
const prev = prevMonthYm(oldest)
|
||||
if (chain.includes(prev)) return
|
||||
if (prev < '2000-01') return
|
||||
setListLoadingMore(true)
|
||||
try {
|
||||
const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
|
||||
skipQualityFilter: true,
|
||||
collapseDuplicateSessions: true,
|
||||
month: prev,
|
||||
})
|
||||
const newChain = [...chain, prev]
|
||||
monthsIncludedRef.current = newChain
|
||||
setMonthsIncluded(newChain)
|
||||
setEntries((cur) => dedupeActivitiesById([...cur, ...more]))
|
||||
} finally {
|
||||
setListLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const oldestLoadedYm = monthsIncluded.length ? monthsIncluded[monthsIncluded.length - 1] : selectedMonth
|
||||
const nextOlderYm = prevMonthYm(oldestLoadedYm)
|
||||
const canLoadOlder = nextOlderYm >= '2000-01' && !monthsIncluded.includes(nextOlderYm)
|
||||
|
||||
const loadUsage = () => {
|
||||
api.getFeatureUsage().then(features => {
|
||||
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
|
||||
|
|
@ -195,16 +497,68 @@ export default function ActivityPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const ym = ymdMonth()
|
||||
monthsIncludedRef.current = [ym]
|
||||
setMonthsIncluded([ym])
|
||||
setSelectedMonth(ym)
|
||||
void fetchMonthsChain([ym])
|
||||
loadUsage()
|
||||
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
||||
},[])
|
||||
}, [fetchMonthsChain])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing?.id) {
|
||||
setSessionDetail(null)
|
||||
setMetricDraft({})
|
||||
setSessionLoadError(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setSessionLoadError(null)
|
||||
;(async () => {
|
||||
try {
|
||||
const d = await api.getActivitySession(editing.id)
|
||||
if (!cancelled) setSessionDetail(d)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setSessionDetail(null)
|
||||
setSessionLoadError(err.message || 'Zusatzfelder konnten nicht geladen werden')
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [editing?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionDetail) {
|
||||
setMetricDraft({})
|
||||
return
|
||||
}
|
||||
const m = {}
|
||||
for (const row of sessionDetail.metrics || []) {
|
||||
m[row.key] = row.value
|
||||
}
|
||||
for (const s of sessionDetail.schema || []) {
|
||||
if (!(s.key in m)) {
|
||||
m[s.key] = s.data_type === 'boolean' ? false : ''
|
||||
}
|
||||
}
|
||||
setMetricDraft(m)
|
||||
}, [sessionDetail])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {...form}
|
||||
payload.start_time =
|
||||
payload.start_time === '' || payload.start_time == null
|
||||
? null
|
||||
: timePayloadFromInput(payload.start_time)
|
||||
payload.end_time =
|
||||
payload.end_time === '' || payload.end_time == null
|
||||
? null
|
||||
: timePayloadFromInput(payload.end_time)
|
||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||
|
|
@ -226,9 +580,64 @@ export default function ActivityPage() {
|
|||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setSavingEdit(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = { ...editing }
|
||||
delete payload.id
|
||||
for (const s of sessionDetail?.schema || []) {
|
||||
const col = s.source_field
|
||||
if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue
|
||||
if (!(s.key in metricDraft)) continue
|
||||
const raw = metricDraft[s.key]
|
||||
const rawStr = raw === null || raw === undefined ? '' : String(raw).trim()
|
||||
if (rawStr === '') {
|
||||
payload[col] = null
|
||||
continue
|
||||
}
|
||||
let v = rawStr
|
||||
if (s.data_type === 'integer') {
|
||||
v = parseInt(rawStr, 10)
|
||||
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
|
||||
} else if (s.data_type === 'float') {
|
||||
v = parseFloat(rawStr)
|
||||
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
|
||||
} else if (s.data_type === 'boolean') {
|
||||
v = !!raw
|
||||
} else {
|
||||
v = rawStr
|
||||
}
|
||||
payload[col] = v
|
||||
}
|
||||
if (payload.duration_min !== '' && payload.duration_min != null) payload.duration_min = parseFloat(payload.duration_min)
|
||||
if (payload.kcal_active !== '' && payload.kcal_active != null) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||
if (payload.hr_avg !== '' && payload.hr_avg != null) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||
if (payload.hr_max !== '' && payload.hr_max != null) payload.hr_max = parseFloat(payload.hr_max)
|
||||
if (payload.rpe !== '' && payload.rpe != null) payload.rpe = parseInt(payload.rpe, 10)
|
||||
payload.start_time =
|
||||
payload.start_time === '' || payload.start_time == null
|
||||
? null
|
||||
: timePayloadFromInput(payload.start_time)
|
||||
payload.end_time =
|
||||
payload.end_time === '' || payload.end_time == null
|
||||
? null
|
||||
: timePayloadFromInput(payload.end_time)
|
||||
await api.updateActivity(editing.id, payload)
|
||||
setEditing(null); await load()
|
||||
if (sessionDetail?.schema?.length > 0) {
|
||||
const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
|
||||
await api.putActivityMetrics(editing.id, { metrics })
|
||||
}
|
||||
setEditing(null)
|
||||
setSessionDetail(null)
|
||||
startTransition(() => {
|
||||
void load()
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err.message || 'Speichern fehlgeschlagen')
|
||||
setTimeout(() => setError(null), 6000)
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
|
|
@ -266,12 +675,19 @@ export default function ActivityPage() {
|
|||
</div>
|
||||
|
||||
{/* Übersicht */}
|
||||
{stats && stats.count>0 && (
|
||||
{stats && (stats.total_in_profile > 0 || stats.count > 0) && (
|
||||
<div className="card section-gap">
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
||||
<strong>{stats.total_in_profile ?? '–'}</strong> Einträge im Profil (gleicher Filter wie diese Seite). Die Summen
|
||||
Kcal/Stunden beziehen sich auf die <strong>neuesten {stats.sample_size ?? stats.count}</strong> Einträge (max.
|
||||
30).
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
|
||||
{[['Trainings',stats.count,'var(--text1)'],
|
||||
['Kcal gesamt',Math.round(stats.total_kcal),'#EF9F27'],
|
||||
['Stunden',Math.round(stats.total_min/60*10)/10,'#378ADD']].map(([l,v,c])=>(
|
||||
{[
|
||||
['Neueste (max. 30)', stats.count, 'var(--text1)'],
|
||||
['Kcal (darin)', Math.round(stats.total_kcal), '#EF9F27'],
|
||||
['Stunden (darin)', Math.round(stats.total_min / 60 * 10) / 10, '#378ADD'],
|
||||
].map(([l, v, c]) => (
|
||||
<div key={l} style={{flex:1,minWidth:80,background:'var(--surface2)',borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
|
||||
<div style={{fontSize:18,fontWeight:700,color:c}}>{v}</div>
|
||||
<div style={{fontSize:10,color:'var(--text3)'}}>{l}</div>
|
||||
|
|
@ -335,6 +751,37 @@ export default function ActivityPage() {
|
|||
|
||||
{tab==='list' && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label className="form-label" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
Monat
|
||||
<input
|
||||
type="month"
|
||||
className="form-input"
|
||||
value={selectedMonth}
|
||||
onChange={onMonthPickerChange}
|
||||
style={{ width: 'auto', minWidth: 150, margin: 0 }}
|
||||
/>
|
||||
</label>
|
||||
{monthsIncluded.length > 1 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '}
|
||||
{dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Hier sind <strong>alle</strong> Trainings sichtbar (Profil-Qualitätsfilter aus — auch ohne Bewertung oder bei
|
||||
abweichender Einordnung). Unter „Verlauf“ / Auswertung bleibt der Filter aktiv. Es wird jeweils ein
|
||||
kompletter Kalendermonat geladen; „Vorheriger Monat“ hängt den nächstälteren Monat an (ohne OFFSET-Pagination).
|
||||
</p>
|
||||
{entries.length===0 && (
|
||||
<div className="empty-state">
|
||||
<h3>Keine Trainings</h3>
|
||||
|
|
@ -347,8 +794,28 @@ export default function ActivityPage() {
|
|||
return (
|
||||
<div key={e.id} className="card" style={{marginBottom:8,borderLeft:`3px solid ${color}`}}>
|
||||
{isEd ? (
|
||||
<EntryForm form={editing} setForm={setEditing}
|
||||
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Speichern"/>
|
||||
<EntryForm
|
||||
form={editing}
|
||||
setForm={setEditing}
|
||||
onSave={handleUpdate}
|
||||
onCancel={() => { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }}
|
||||
saveLabel="Speichern"
|
||||
saving={savingEdit}
|
||||
error={error}
|
||||
formExtras={
|
||||
<>
|
||||
{sessionLoadError && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>{sessionLoadError}</div>
|
||||
)}
|
||||
<SessionMetricsFields
|
||||
schema={sessionDetail?.schema}
|
||||
metrics={sessionDetail?.metrics}
|
||||
values={metricDraft}
|
||||
setValues={setMetricDraft}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
|
||||
|
|
@ -435,7 +902,12 @@ export default function ActivityPage() {
|
|||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
|
||||
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
|
||||
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
|
||||
{(formatTimeForList(e.start_time) || formatTimeForList(e.end_time)) && (
|
||||
<span>
|
||||
{formatTimeForList(e.start_time) && ` · Start ${formatTimeForList(e.start_time)}`}
|
||||
{formatTimeForList(e.end_time) && ` · Ende ${formatTimeForList(e.end_time)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:10,flexWrap:'wrap'}}>
|
||||
{e.duration_min && <span style={{fontSize:12,color:'var(--text2)'}}>⏱ {Math.round(e.duration_min)} Min</span>}
|
||||
|
|
@ -458,6 +930,21 @@ export default function ActivityPage() {
|
|||
</div>
|
||||
)
|
||||
})}
|
||||
{canLoadOlder && (
|
||||
<div style={{ marginTop: 12, marginBottom: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%' }}
|
||||
disabled={listLoadingMore}
|
||||
onClick={() => void loadPreviousMonth()}
|
||||
>
|
||||
{listLoadingMore
|
||||
? 'Lade…'
|
||||
: `Vorherigen Monat laden (${dayjs(`${nextOlderYm}-01`).format('MMMM YYYY')})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
912
frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
Normal file
912
frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
Normal file
|
|
@ -0,0 +1,912 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Trash2, Save, RefreshCw, Pencil } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
|
||||
const DATA_TYPES = ['integer', 'float', 'string', 'boolean']
|
||||
|
||||
const emptyParamForm = () => ({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
category: 'physical',
|
||||
data_type: 'float',
|
||||
unit: '',
|
||||
source_field: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
export default function AdminActivityAttributeProfilesPage() {
|
||||
const [tab, setTab] = useState('catalog')
|
||||
const [params, setParams] = useState([])
|
||||
const [includeInactive, setIncludeInactive] = useState(false)
|
||||
const [catMeta, setCatMeta] = useState({})
|
||||
const [flatTypes, setFlatTypes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const [showParamForm, setShowParamForm] = useState(false)
|
||||
const [paramForm, setParamForm] = useState(emptyParamForm())
|
||||
const [editParam, setEditParam] = useState(null)
|
||||
|
||||
const [selCategory, setSelCategory] = useState('cardio')
|
||||
const [catLinks, setCatLinks] = useState([])
|
||||
const [catAdd, setCatAdd] = useState({
|
||||
training_parameter_id: '',
|
||||
sort_order: 0,
|
||||
required: false,
|
||||
ui_group: '',
|
||||
})
|
||||
const [editingCatId, setEditingCatId] = useState(null)
|
||||
const [catDraft, setCatDraft] = useState({ sort_order: 0, required: false, ui_group: '' })
|
||||
|
||||
const [selTypeId, setSelTypeId] = useState('')
|
||||
const [typeLinks, setTypeLinks] = useState([])
|
||||
const [typeAdd, setTypeAdd] = useState({
|
||||
training_parameter_id: '',
|
||||
sort_order: '',
|
||||
required: '',
|
||||
ui_group: '',
|
||||
})
|
||||
const [editingTypeId, setEditingTypeId] = useState(null)
|
||||
const [typeDraft, setTypeDraft] = useState({ sort_order: '', required: '', ui_group: '' })
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 2800)
|
||||
}
|
||||
|
||||
const refreshCatalog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [p, cats, flat] = await Promise.all([
|
||||
api.adminListTrainingParameters(includeInactive),
|
||||
api.getTrainingCategories(),
|
||||
api.listTrainingTypesFlat(),
|
||||
])
|
||||
setParams(Array.isArray(p) ? p : [])
|
||||
setCatMeta(cats && typeof cats === 'object' ? cats : {})
|
||||
setFlatTypes(Array.isArray(flat) ? flat : [])
|
||||
setSelTypeId((prev) => (prev ? prev : flat?.length ? String(flat[0].id) : ''))
|
||||
} catch (e) {
|
||||
setError(e.message || 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [includeInactive])
|
||||
|
||||
useEffect(() => {
|
||||
refreshCatalog()
|
||||
}, [refreshCatalog])
|
||||
|
||||
const loadCatLinks = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.adminListTrainingCategoryParameters(selCategory)
|
||||
setCatLinks(Array.isArray(data) ? data : [])
|
||||
} catch (e) {
|
||||
setError(e.message || 'Kategorie-Zuordnungen')
|
||||
}
|
||||
}, [selCategory])
|
||||
|
||||
useEffect(() => {
|
||||
loadCatLinks()
|
||||
}, [loadCatLinks])
|
||||
|
||||
const loadTypeLinks = useCallback(async () => {
|
||||
if (!selTypeId) {
|
||||
setTypeLinks([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.adminListTrainingTypeParameters(Number(selTypeId))
|
||||
setTypeLinks(Array.isArray(data) ? data : [])
|
||||
} catch (e) {
|
||||
setError(e.message || 'Typ-Zuordnungen')
|
||||
}
|
||||
}, [selTypeId])
|
||||
|
||||
useEffect(() => {
|
||||
loadTypeLinks()
|
||||
}, [loadTypeLinks])
|
||||
|
||||
const activeParams = params.filter((p) => p.is_active !== false)
|
||||
|
||||
const categoryKeys =
|
||||
Object.keys(catMeta).length > 0
|
||||
? Object.keys(catMeta).sort()
|
||||
: ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
|
||||
|
||||
const saveNewParameter = async () => {
|
||||
setError(null)
|
||||
if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) {
|
||||
setError('key, name_de und name_en sind Pflicht.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.adminCreateTrainingParameter({
|
||||
key: paramForm.key.trim().toLowerCase(),
|
||||
name_de: paramForm.name_de.trim(),
|
||||
name_en: paramForm.name_en.trim(),
|
||||
category: paramForm.category,
|
||||
data_type: paramForm.data_type,
|
||||
unit: paramForm.unit.trim() || null,
|
||||
source_field: paramForm.source_field.trim() || null,
|
||||
is_active: paramForm.is_active,
|
||||
validation_rules: {},
|
||||
})
|
||||
showToast('Parameter angelegt')
|
||||
setShowParamForm(false)
|
||||
setParamForm(emptyParamForm())
|
||||
await refreshCatalog()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const saveEditedParameter = async () => {
|
||||
if (!editParam) return
|
||||
setError(null)
|
||||
try {
|
||||
await api.adminUpdateTrainingParameter(editParam.id, {
|
||||
name_de: editParam.name_de.trim(),
|
||||
name_en: editParam.name_en.trim(),
|
||||
category: editParam.category,
|
||||
data_type: editParam.data_type,
|
||||
unit: editParam.unit?.trim() || null,
|
||||
source_field: editParam.source_field?.trim() || null,
|
||||
is_active: !!editParam.is_active,
|
||||
validation_rules: editParam.validation_rules || {},
|
||||
})
|
||||
showToast('Parameter gespeichert')
|
||||
setEditParam(null)
|
||||
await refreshCatalog()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Update fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const deactivateParameter = async (id) => {
|
||||
if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return
|
||||
try {
|
||||
await api.adminDeleteTrainingParameter(id)
|
||||
showToast('Deaktiviert')
|
||||
await refreshCatalog()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const addCatLink = async () => {
|
||||
const pid = parseInt(catAdd.training_parameter_id, 10)
|
||||
if (!pid) {
|
||||
setError('Parameter-ID wählen')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.adminAddTrainingCategoryParameter({
|
||||
training_category: selCategory,
|
||||
training_parameter_id: pid,
|
||||
sort_order: Number(catAdd.sort_order) || 0,
|
||||
required: !!catAdd.required,
|
||||
ui_group: catAdd.ui_group.trim() || null,
|
||||
})
|
||||
showToast('Zuordnung gespeichert')
|
||||
setCatAdd({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '' })
|
||||
await loadCatLinks()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Konflikt oder ungültige Daten')
|
||||
}
|
||||
}
|
||||
|
||||
const saveCatLink = async (linkId) => {
|
||||
try {
|
||||
await api.adminUpdateTrainingCategoryParameter(linkId, {
|
||||
sort_order: Number(catDraft.sort_order) || 0,
|
||||
required: !!catDraft.required,
|
||||
ui_group: catDraft.ui_group.trim() || null,
|
||||
})
|
||||
showToast('Kategorie-Zuordnung aktualisiert')
|
||||
setEditingCatId(null)
|
||||
await loadCatLinks()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Update fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const addTypeLink = async () => {
|
||||
const tid = Number(selTypeId)
|
||||
const pid = parseInt(typeAdd.training_parameter_id, 10)
|
||||
if (!tid || !pid) {
|
||||
setError('Trainingstyp und Parameter wählen')
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
training_type_id: tid,
|
||||
training_parameter_id: pid,
|
||||
sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order),
|
||||
required:
|
||||
typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true,
|
||||
ui_group: typeAdd.ui_group.trim() || null,
|
||||
}
|
||||
try {
|
||||
await api.adminAddTrainingTypeParameter(body)
|
||||
showToast('Typ-Zuordnung gespeichert')
|
||||
setTypeAdd({ training_parameter_id: '', sort_order: '', required: '', ui_group: '' })
|
||||
await loadTypeLinks()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Konflikt oder ungültige Daten')
|
||||
}
|
||||
}
|
||||
|
||||
const saveTypeLink = async (linkId) => {
|
||||
try {
|
||||
const payload = {}
|
||||
if (typeDraft.sort_order !== '' && typeDraft.sort_order != null) {
|
||||
payload.sort_order = Number(typeDraft.sort_order)
|
||||
} else {
|
||||
payload.sort_order = null
|
||||
}
|
||||
if (typeDraft.required === '' || typeDraft.required == null) {
|
||||
payload.required = null
|
||||
} else {
|
||||
payload.required = typeDraft.required === true || typeDraft.required === 'true'
|
||||
}
|
||||
payload.ui_group = typeDraft.ui_group.trim() || null
|
||||
await api.adminUpdateTrainingTypeParameter(linkId, payload)
|
||||
showToast('Typ-Zuordnung aktualisiert')
|
||||
setEditingTypeId(null)
|
||||
await loadTypeLinks()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Update fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !params.length) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="spinner" /> Lade…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="capture-page">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
|
||||
← Training (Hub)
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="page-title">Session-Metriken (EAV)</h1>
|
||||
|
||||
<div
|
||||
className="card section-gap"
|
||||
style={{ background: 'var(--surface2)', border: '1px solid var(--border)', fontSize: 13, lineHeight: 1.55 }}
|
||||
>
|
||||
<strong>Hinweise</strong>
|
||||
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
||||
<li>
|
||||
<strong>Daten:</strong> Offizielle Migrationen löschen keine Zeilen in <code>activity_log</code>. Leere
|
||||
Tabellen nach Deploy deuten auf neues DB-Volume, manuelles SQL oder falsche Umgebung hin – vor Prod immer{' '}
|
||||
<code>pg_dump</code>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Doppelzuordnung:</strong> Dieselbe Metrik darf <em>eine</em> Zeile pro Kategorie und <em>eine</em>{' '}
|
||||
pro Trainingstyp haben (Unique-Constraint). Wenn dieselbe Metrik in <strong>Kategorie</strong> und{' '}
|
||||
<strong>Trainingstyp</strong> vorkommt, überschreibt die <strong>Typ-Zeile</strong> sort/required/ui_group
|
||||
(Merge in Layer 1) – kein Datenfehler.
|
||||
</li>
|
||||
<li>
|
||||
Nach Migration <strong>055</strong> werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
|
||||
<code>activity_log</code>-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ background: 'var(--accent-light)', color: 'var(--accent-dark)', marginBottom: 12 }}
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ background: '#FCEBEB', color: '#D85A30', marginBottom: 12, fontSize: 14 }}
|
||||
>
|
||||
{error}
|
||||
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setError(null)}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tabs" style={{ marginBottom: 16, overflowX: 'auto' }}>
|
||||
{[
|
||||
['catalog', 'Katalog'],
|
||||
['category', 'Kategorie'],
|
||||
['type', 'Trainingstyp'],
|
||||
].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={'tab' + (tab === id ? ' active' : '')}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'catalog' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>Parameter-Katalog</span>
|
||||
<label style={{ fontSize: 12, fontWeight: 400, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeInactive}
|
||||
onChange={(e) => setIncludeInactive(e.target.checked)}
|
||||
/>
|
||||
Inaktive
|
||||
</label>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => refreshCatalog()}>
|
||||
<RefreshCw size={14} /> Neu laden
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowParamForm((v) => !v)}>
|
||||
<Plus size={14} /> Neu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showParamForm && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div className="form-row">
|
||||
<label className="form-label">key</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="z. B. avg_power"
|
||||
value={paramForm.key}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">name_de / name_en</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.name_de}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.name_en}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe / Datentyp</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={paramForm.category}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
|
||||
>
|
||||
{PARAM_GROUP.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={paramForm.data_type}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
|
||||
>
|
||||
{DATA_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Einheit / source_field</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.unit}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.source_field}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
|
||||
<Save size={14} /> Anlegen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowParamForm(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editParam && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div className="card-title" style={{ fontSize: 14 }}>
|
||||
Bearbeiten: <code>{editParam.key}</code>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">name_de / name_en</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.name_de || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.name_en || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe / Typ</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={editParam.category}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
|
||||
>
|
||||
{PARAM_GROUP.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={editParam.data_type}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, data_type: e.target.value }))}
|
||||
>
|
||||
{DATA_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Einheit / source_field</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.unit || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.source_field || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!editParam.is_active}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, is_active: e.target.checked }))}
|
||||
/>
|
||||
aktiv
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={saveEditedParameter}>
|
||||
<Save size={14} /> Speichern
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditParam(null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="data-table" style={{ width: '100%', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>key</th>
|
||||
<th>DE</th>
|
||||
<th>Typ</th>
|
||||
<th>aktiv</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{params.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td>
|
||||
<code>{r.key}</code>
|
||||
</td>
|
||||
<td>{r.name_de}</td>
|
||||
<td>
|
||||
{r.data_type} · {r.category}
|
||||
</td>
|
||||
<td>{r.is_active === false ? 'nein' : 'ja'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => setEditParam({ ...r })}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{r.is_active !== false && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => deactivateParameter(r.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'category' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Kategorie</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selCategory}
|
||||
onChange={(e) => setSelCategory(e.target.value)}
|
||||
style={{ maxWidth: 280 }}
|
||||
>
|
||||
{categoryKeys.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{catMeta[k]?.name_de || k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label className="form-label">Parameter</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={catAdd.training_parameter_id}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{activeParams.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id} · {p.key} ({p.name_de})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">sort</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 80 }}
|
||||
value={catAdd.sort_order}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={catAdd.required}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, required: e.target.checked }))}
|
||||
/>
|
||||
Pflicht
|
||||
</label>
|
||||
<div>
|
||||
<label className="form-label">ui_group</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 120 }}
|
||||
value={catAdd.ui_group}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={addCatLink}>
|
||||
<Plus size={14} /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
|
||||
{catLinks.map((l) => (
|
||||
<li
|
||||
key={l.id}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{editingCatId === l.id ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
|
||||
<span style={{ flex: '1 1 200px' }}>
|
||||
<strong>{l.parameter_key}</strong> · {l.parameter_name_de}
|
||||
</span>
|
||||
<div>
|
||||
<label className="form-label">sort</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 72 }}
|
||||
value={catDraft.sort_order}
|
||||
onChange={(e) => setCatDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!catDraft.required}
|
||||
onChange={(e) => setCatDraft((d) => ({ ...d, required: e.target.checked }))}
|
||||
/>
|
||||
Pflicht
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
placeholder="ui_group"
|
||||
value={catDraft.ui_group}
|
||||
onChange={(e) => setCatDraft((d) => ({ ...d, ui_group: e.target.value }))}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={() => saveCatLink(l.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditingCatId(null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||
<span>
|
||||
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
|
||||
{l.required ? ' · Pflicht' : ''}
|
||||
{l.ui_group ? ` · ${l.ui_group}` : ''}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => {
|
||||
setEditingCatId(l.id)
|
||||
setCatDraft({
|
||||
sort_order: l.sort_order,
|
||||
required: !!l.required,
|
||||
ui_group: l.ui_group || '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Zuordnung entfernen?')) return
|
||||
await api.adminDeleteTrainingCategoryParameter(l.id)
|
||||
await loadCatLinks()
|
||||
showToast('Entfernt')
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'type' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainingstyp</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selTypeId}
|
||||
onChange={(e) => setSelTypeId(e.target.value)}
|
||||
style={{ maxWidth: 420 }}
|
||||
>
|
||||
{flatTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.id} · {t.name_de} ({t.category})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label className="form-label">Parameter</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={typeAdd.training_parameter_id}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{activeParams.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id} · {p.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">sort (leer=Erben)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 80 }}
|
||||
value={typeAdd.sort_order}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Pflicht (leer=Erben)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
value={typeAdd.required}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="true">ja</option>
|
||||
<option value="false">nein</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">ui_group</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 120 }}
|
||||
value={typeAdd.ui_group}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={addTypeLink}>
|
||||
<Plus size={14} /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
|
||||
{typeLinks.map((l) => (
|
||||
<li
|
||||
key={l.id}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{editingTypeId === l.id ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
|
||||
<span style={{ flex: '1 1 200px' }}>
|
||||
<strong>{l.parameter_key}</strong>
|
||||
</span>
|
||||
<div>
|
||||
<label className="form-label">sort</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 72 }}
|
||||
value={typeDraft.sort_order}
|
||||
onChange={(e) => setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
value={typeDraft.required}
|
||||
onChange={(e) => setTypeDraft((d) => ({ ...d, required: e.target.value }))}
|
||||
>
|
||||
<option value="">Erben</option>
|
||||
<option value="true">ja</option>
|
||||
<option value="false">nein</option>
|
||||
</select>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
placeholder="ui_group"
|
||||
value={typeDraft.ui_group}
|
||||
onChange={(e) => setTypeDraft((d) => ({ ...d, ui_group: e.target.value }))}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={() => saveTypeLink(l.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditingTypeId(null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||
<span>
|
||||
<strong>{l.parameter_key}</strong> · sort {l.sort_order ?? '—'} · Pflicht{' '}
|
||||
{l.required === null || l.required === undefined
|
||||
? '—'
|
||||
: l.required
|
||||
? 'ja'
|
||||
: 'nein'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => {
|
||||
setEditingTypeId(l.id)
|
||||
setTypeDraft({
|
||||
sort_order: l.sort_order ?? '',
|
||||
required:
|
||||
l.required === null || l.required === undefined
|
||||
? ''
|
||||
: l.required
|
||||
? 'true'
|
||||
: 'false',
|
||||
ui_group: l.ui_group || '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Zuordnung entfernen?')) return
|
||||
await api.adminDeleteTrainingTypeParameter(l.id)
|
||||
await loadTypeLinks()
|
||||
showToast('Entfernt')
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -297,10 +297,19 @@ export default function AdminCsvTemplateEditorPage() {
|
|||
const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate'
|
||||
const targetOptions = useMemo(() => {
|
||||
if (!modMeta?.fields || aggregateSleepImport) return []
|
||||
return Object.entries(modMeta.fields).map(([key, meta]) => ({
|
||||
const entries = Object.entries(modMeta.fields).map(([key, meta]) => {
|
||||
const title = meta.label_de || meta.name_de || key
|
||||
return {
|
||||
value: key,
|
||||
label: `${key}${meta.required ? ' *' : ''}`,
|
||||
}))
|
||||
label: `${title}${meta.required ? ' *' : ''}`,
|
||||
group: meta.from_training_parameter ? 'eav' : 'log',
|
||||
}
|
||||
})
|
||||
entries.sort((a, b) => {
|
||||
if (a.group !== b.group) return a.group === 'log' ? -1 : 1
|
||||
return a.label.localeCompare(b.label, 'de')
|
||||
})
|
||||
return entries
|
||||
}, [modMeta, aggregateSleepImport])
|
||||
|
||||
const requiredTargets = useMemo(() => {
|
||||
|
|
@ -1025,11 +1034,23 @@ export default function AdminCsvTemplateEditorPage() {
|
|||
}}
|
||||
>
|
||||
<option value="-">— ignorieren</option>
|
||||
{targetOptions.map((o) => (
|
||||
{['log', 'eav'].map((g) => {
|
||||
const opts = targetOptions.filter((o) => o.group === g)
|
||||
if (!opts.length) return null
|
||||
const ogLabel =
|
||||
g === 'log'
|
||||
? 'Activity — Kernfelder (activity_log)'
|
||||
: 'Trainingsparameter (EAV)'
|
||||
return (
|
||||
<optgroup key={g} label={ogLabel}>
|
||||
{opts.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -138,16 +138,30 @@ export const api = {
|
|||
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
|
||||
|
||||
// Activity
|
||||
/** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert */
|
||||
listActivity: (limit=200, days)=> {
|
||||
/**
|
||||
* @param {number} [limit=200]
|
||||
* @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert
|
||||
* @param {{ offset?: number, skipQualityFilter?: boolean, month?: string, collapseDuplicateSessions?: boolean }} [opts] month = YYYY-MM (schließt days/offset aus)
|
||||
*/
|
||||
listActivity: (limit=200, days, opts={})=> {
|
||||
const q = new URLSearchParams({ limit: String(limit) })
|
||||
if (days != null && days !== '') q.set('days', String(days))
|
||||
if (opts.month) q.set('month', String(opts.month))
|
||||
if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset))
|
||||
if (opts.collapseDuplicateSessions) q.set('collapse_duplicate_sessions', 'true')
|
||||
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
|
||||
return req(`/activity?${q}`)
|
||||
},
|
||||
createActivity: (d) => req('/activity',json(d)),
|
||||
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
||||
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
||||
activityStats: () => req('/activity/stats'),
|
||||
/** @param {{ skipQualityFilter?: boolean }} [opts] */
|
||||
activityStats: (opts={}) => {
|
||||
const q = new URLSearchParams()
|
||||
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
|
||||
const qs = q.toString()
|
||||
return req(`/activity/stats${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
listUncategorizedActivities: () => req('/activity/uncategorized'),
|
||||
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
|
||||
importActivityCsv: async(file)=>{
|
||||
|
|
@ -314,6 +328,34 @@ export const api = {
|
|||
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
|
||||
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
|
||||
|
||||
// Admin: Training session metrics (EAV) & attribute profiles (Migration 054)
|
||||
adminListTrainingParameters: (includeInactive = false) =>
|
||||
req(`/admin/training-parameters${includeInactive ? '?include_inactive=true' : ''}`),
|
||||
adminCreateTrainingParameter: (d) => req('/admin/training-parameters', json(d)),
|
||||
adminUpdateTrainingParameter: (id, d) => req(`/admin/training-parameters/${id}`, jput(d)),
|
||||
adminDeleteTrainingParameter: (id) =>
|
||||
req(`/admin/training-parameters/${id}`, { method: 'DELETE' }),
|
||||
adminListTrainingCategoryParameters: (category = '') =>
|
||||
req(
|
||||
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
|
||||
),
|
||||
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
|
||||
adminUpdateTrainingCategoryParameter: (id, d) =>
|
||||
req(`/admin/training-category-parameters/${id}`, jput(d)),
|
||||
adminDeleteTrainingCategoryParameter: (id) =>
|
||||
req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
|
||||
adminListTrainingTypeParameters: (trainingTypeId) =>
|
||||
req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
|
||||
adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
|
||||
adminUpdateTrainingTypeParameter: (id, d) =>
|
||||
req(`/admin/training-type-parameters/${id}`, jput(d)),
|
||||
adminDeleteTrainingTypeParameter: (id) =>
|
||||
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
|
||||
putActivityMetrics: (id, body) =>
|
||||
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
|
||||
|
||||
// Sleep Module (v9d Phase 2b)
|
||||
listSleep: (l=90) => req(`/sleep?limit=${l}`),
|
||||
getSleepByDate: (date) => req(`/sleep/by-date/${date}`),
|
||||
|
|
|
|||
35
scripts/backup/mitai_pg_dump.sh
Normal file
35
scripts/backup/mitai_pg_dump.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
# Volles PostgreSQL-Backup im Custom-Format (pg_restore-kompatibel).
|
||||
# Auf dem Host ausführen, wo der Postgres-Container läuft (z. B. Raspberry Pi mit docker compose).
|
||||
#
|
||||
# BACKUP_DIR=/path/to/safe/storage ./scripts/backup/mitai_pg_dump.sh
|
||||
#
|
||||
# Variablen (optional): POSTGRES_CONTAINER, POSTGRES_DB, POSTGRES_USER, BACKUP_DIR
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="${POSTGRES_CONTAINER:-mitai-db-prod}"
|
||||
DB="${POSTGRES_DB:-mitai_prod}"
|
||||
USER="${POSTGRES_USER:-mitai_prod}"
|
||||
OUT_DIR="${BACKUP_DIR:-.}"
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "${OUT_DIR}"
|
||||
OUT="${OUT_DIR}/${DB}_${STAMP}.dump"
|
||||
|
||||
if ! docker inspect "$CONTAINER" &>/dev/null; then
|
||||
echo "Container nicht gefunden: $CONTAINER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec "$CONTAINER" pg_dump -U "$USER" -Fc --no-owner --no-acl "$DB" > "$OUT"
|
||||
ls -la "$OUT"
|
||||
echo "OK: $OUT (zum Zurückspielen: siehe Kommentar unten in diesem Skript)"
|
||||
|
||||
# ── Restore (nur bei Notfall; Backend vorher stoppen, sonst offene Verbindungen) ──
|
||||
# docker compose stop backend
|
||||
# docker cp "$OUT" "$CONTAINER:/tmp/restore.dump"
|
||||
# docker exec "$CONTAINER" pg_restore -U "$USER" -d "$DB" --clean --if-exists --no-owner --no-acl /tmp/restore.dump
|
||||
# docker compose start backend
|
||||
#
|
||||
# Hinweis: --clean entfernt Objekte vor dem Wiederherstellen; kurze Unterbrechung der DB.
|
||||
# Für „nur lesen“ Backup reicht die .dump-Datei auf externem Medium zu kopieren.
|
||||
Loading…
Reference in New Issue
Block a user