- Updated the CSV import logic to merge active training parameters with static fields for the activity module, improving field mapping accuracy. - Enhanced validation functions to incorporate dynamic field definitions based on active training parameters, ensuring better data integrity during imports. - Refactored related functions to streamline the process of handling CSV templates and field mappings, improving maintainability and clarity. - Added new utility functions for resolving activity log column patches and upserting session metrics from CSV, enhancing the overall import functionality.
165 lines
6.8 KiB
Python
165 lines
6.8 KiB
Python
"""
|
|
Ziel-Module für CSV-Import: Tabellen-Felder, Pflichtfelder, Duplikat-Strategie (Issue #21).
|
|
|
|
Hinweis: blood_pressure nutzt in der DB measured_at; Logik-Felder measured_date + measured_time
|
|
werden im Executor zu measured_at zusammengefügt (Phase Import-Executor).
|
|
|
|
Activity: date kann aus start_time (ISO-Datetime) abgeleitet werden, wenn nur start_time gesetzt ist.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, cast
|
|
|
|
MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|
"nutrition": {
|
|
"table": "nutrition_log",
|
|
"fields": {
|
|
"date": {"type": "date", "required": True},
|
|
"kcal": {"type": "float", "required": False, "unit": "kcal"},
|
|
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
|
"fat_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
|
"carbs_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
|
},
|
|
"duplicate_key": ["profile_id", "date"],
|
|
"duplicate_strategy": "update",
|
|
# Legacy-Fallback wenn die Vorlage kein import_row_processing speichert — Vorlagen mittelfristig explizit.
|
|
"import_row_processing_default": {
|
|
"group_by": ["date"],
|
|
"aggregates": {
|
|
"kcal": "sum",
|
|
"protein_g": "sum",
|
|
"fat_g": "sum",
|
|
"carbs_g": "sum",
|
|
},
|
|
},
|
|
},
|
|
"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},
|
|
},
|
|
"derive_date_from_datetime_field": "start_time",
|
|
"duplicate_key": ["profile_id", "date", "start_time"],
|
|
"duplicate_strategy": "update",
|
|
},
|
|
"sleep": {
|
|
"table": "sleep_log",
|
|
"fields": {},
|
|
"import_mode": "apple_sleep_aggregate",
|
|
},
|
|
"vitals_baseline": {
|
|
"table": "vitals_baseline",
|
|
"fields": {
|
|
"date": {"type": "date", "required": True},
|
|
"resting_hr": {"type": "int", "required": False},
|
|
"hrv": {"type": "int", "required": False},
|
|
"vo2_max": {"type": "float", "required": False},
|
|
"spo2": {"type": "int", "required": False},
|
|
"respiratory_rate": {"type": "float", "required": False},
|
|
},
|
|
"duplicate_key": ["profile_id", "date"],
|
|
"duplicate_strategy": "update",
|
|
# Legacy-Fallback — Vorlagen mittelfristig explizit setzen.
|
|
"import_row_processing_default": {
|
|
"group_by": ["date"],
|
|
"aggregates": {
|
|
"resting_hr": "mean",
|
|
"hrv": "mean",
|
|
"vo2_max": "mean",
|
|
"spo2": "mean",
|
|
"respiratory_rate": "mean",
|
|
},
|
|
},
|
|
},
|
|
"blood_pressure": {
|
|
"table": "blood_pressure_log",
|
|
"fields": {
|
|
"measured_date": {"type": "date", "required": True},
|
|
"measured_time": {"type": "time", "required": True},
|
|
# Apple Health: eine Spalte „Start“ / „Datum/Uhrzeit“ (Datetime); Executor splittet.
|
|
"start_time": {"type": "datetime", "required": False},
|
|
"systolic": {"type": "int", "required": True},
|
|
"diastolic": {"type": "int", "required": True},
|
|
"pulse": {"type": "int", "required": False},
|
|
},
|
|
"logical_to_db": "blood_pressure_composite_measured_at",
|
|
"duplicate_key": ["profile_id", "measured_at"],
|
|
"duplicate_strategy": "update",
|
|
},
|
|
"weight": {
|
|
"table": "weight_log",
|
|
"fields": {
|
|
"date": {"type": "date", "required": True},
|
|
"weight": {"type": "float", "required": True, "min": 20, "max": 400, "unit": "kg"},
|
|
"note": {"type": "string", "required": False, "max_length": 2000},
|
|
},
|
|
"duplicate_key": ["profile_id", "date"],
|
|
"duplicate_strategy": "update",
|
|
# Legacy-Fallback — Vorlagen mittelfristig explizit setzen.
|
|
"import_row_processing_default": {
|
|
"group_by": ["date"],
|
|
"aggregates": {
|
|
"weight": "last",
|
|
"note": "last",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def get_module_definition(module: str) -> Dict[str, Any] | None:
|
|
return MODULE_DEFINITIONS.get(module)
|
|
|
|
|
|
def list_modules() -> list[str]:
|
|
return sorted(MODULE_DEFINITIONS.keys())
|
|
|
|
|
|
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"):
|
|
raise ValueError(
|
|
f"Modul '{module}' nutzt einen Aggregat-Import ohne Spalten-Mapping; "
|
|
f"alle Spalten müssen „ignorieren“ sein."
|
|
)
|
|
return
|
|
for _csv_col, db_field in field_mappings.items():
|
|
if db_field in ("", None, "-", "_skip"):
|
|
continue
|
|
if db_field not in allowed:
|
|
raise ValueError(f"Ungültiges Zielfeld '{db_field}' für Modul '{module}'")
|
|
|
|
|
|
def validate_required_field_targets(module: str, field_mappings: dict) -> None:
|
|
"""Stellt sicher, dass jedes als required markierte Zielfeld mindestens einer Spalte zugeordnet ist."""
|
|
mod = get_module_definition(module)
|
|
if not mod:
|
|
raise ValueError(f"Unbekanntes Modul: {module}")
|
|
field_defs = cast(dict, mod["fields"])
|
|
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
|
if module == "blood_pressure" and "start_time" in targets:
|
|
targets = set(targets) | {"measured_date", "measured_time"}
|
|
for fname, finfo in field_defs.items():
|
|
if finfo.get("required") and fname not in targets:
|
|
raise ValueError(f"Pflicht-Zielfeld nicht zugeordnet: {fname}")
|