From 026c51b6b589cbb977c12dd049ee56261fa2c9de Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 16 Apr 2026 11:32:16 +0200 Subject: [PATCH] feat: Add CSV import support for additional training parameters - Introduced `resolve_activity_attribute_schema_for_csv_import` to enhance the handling of training parameters during CSV imports, allowing for the inclusion of active parameters not present in the category/type profile. - Updated `apply_activity_mapped_column_aliases` and `upsert_session_metrics_from_csv_mapped` to utilize the new CSV import function, ensuring comprehensive mapping and insertion of metrics. - Added unit tests to validate the new functionality and ensure correct behavior when handling mapped training parameters during CSV imports. --- .../data_layer/activity_session_metrics.py | 76 +++++++++++++++++- .../tests/test_activity_session_metrics.py | 78 +++++++++++++++++++ 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index 89dcd94..1aa295b 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -174,6 +174,74 @@ def resolve_activity_attribute_schema( return merge_parameter_schema_rows(category_rows, type_rows) +def resolve_activity_attribute_schema_for_csv_import( + cur, + training_category: Optional[str], + training_type_id: Optional[int], + mapped: Mapping[str, Any], +) -> List[Dict[str, Any]]: + """ + Wie resolve_activity_attribute_schema, plus alle aktiven training_parameters, deren key in + ``mapped`` vorkommt (nicht Modul-Registry), aber nicht in Kategorie/Typ-Profil — z. B. wenn + ``activity_type_mappings`` fehlt oder der Parameter nur für andere Typen gebucht ist. + + Damit schreibt der Universal-CSV-Import EAV für gültig gemappte Zielfelder wie bei cd29c7d, + sobald der Parameter in ``training_parameters`` existiert. + """ + base = resolve_activity_attribute_schema(cur, training_category, training_type_id) + by_key: Dict[str, Dict[str, Any]] = {s["key"]: s for s in base} + + for k, raw in mapped.items(): + if raw is None or raw == "": + continue + if k in by_key: + continue + if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: + continue + cur.execute( + """ + SELECT + id, + key, + name_de, + name_en, + category AS param_category, + data_type, + unit, + validation_rules, + source_field + FROM training_parameters + WHERE key = %s AND is_active = true + LIMIT 1 + """, + (k,), + ) + row = cur.fetchone() + if not row: + continue + pid = int(row["id"]) + if any(s["training_parameter_id"] == pid for s in by_key.values()): + continue + by_key[k] = { + "training_parameter_id": pid, + "key": row["key"], + "name_de": row["name_de"], + "name_en": row["name_en"], + "param_category": row["param_category"], + "data_type": row["data_type"], + "unit": row["unit"], + "validation_rules": row["validation_rules"] or {}, + "source_field": row["source_field"], + "sort_order": 100_000, + "required": False, + "ui_group": None, + } + + out = list(by_key.values()) + out.sort(key=lambda x: (x.get("sort_order", 0), x["key"])) + return out + + def _validation_rules_dict(raw: Any) -> Dict[str, Any]: if isinstance(raw, dict): return raw @@ -286,7 +354,9 @@ def apply_activity_mapped_column_aliases( training_category: Optional[str], training_type_id: Optional[int], ) -> Dict[str, Any]: - schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) + schema = resolve_activity_attribute_schema_for_csv_import( + cur, training_category, training_type_id, mapped + ) return apply_activity_mapped_column_aliases_from_schema(mapped, schema) @@ -311,7 +381,9 @@ def upsert_session_metrics_from_csv_mapped( row = cur.fetchone() if not row or str(row["profile_id"]) != str(profile_id): return - schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) + schema = resolve_activity_attribute_schema_for_csv_import( + cur, training_category, training_type_id, mapped + ) for spec in schema: pkey = spec["key"] if pkey not in mapped: diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py index 135b8d3..d6f27db 100644 --- a/backend/tests/test_activity_session_metrics.py +++ b/backend/tests/test_activity_session_metrics.py @@ -13,6 +13,7 @@ from data_layer.activity_session_metrics import ( merge_parameter_schema_rows, replace_activity_session_metrics, resolve_activity_attribute_schema, + resolve_activity_attribute_schema_for_csv_import, upsert_session_metrics_from_csv_mapped, _row_value_tuple, _validate_single_value, @@ -323,6 +324,83 @@ def test_apply_mapped_aliases_does_not_overwrite_existing_column(): assert out["hr_avg"] == 120 +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[]) +def test_resolve_csv_import_adds_mapped_keys_from_training_parameters(mock_resolve): + """Ohne Kategorie/Typ-Profil: gemappte Keys aus training_parameters ergänzen.""" + + class Cur: + def __init__(self): + self.sql = "" + + def execute(self, sql, params=None): + self.sql = sql + self.params = params + + def fetchone(self): + if "training_parameters" in self.sql: + return { + "id": 7, + "key": "cadence", + "name_de": "Kadenz", + "name_en": "Cadence", + "param_category": "physical", + "data_type": "integer", + "unit": "rpm", + "validation_rules": {}, + "source_field": None, + } + return None + + cur = Cur() + out = resolve_activity_attribute_schema_for_csv_import(cur, None, None, {"cadence": 90}) + assert len(out) == 1 + assert out[0]["key"] == "cadence" + assert out[0]["training_parameter_id"] == 7 + mock_resolve.assert_called_once_with(cur, None, None) + + +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[]) +def test_upsert_csv_inserts_eav_when_only_mapped_training_parameter(mock_resolve): + class Cur: + def __init__(self): + self.asm_inserts = 0 + self.sql = "" + + def execute(self, sql, params=None): + self.sql = sql + self.params = params + if "INSERT INTO activity_session_metrics" in sql: + self.asm_inserts += 1 + + def fetchone(self): + if "activity_log" in self.sql: + return {"profile_id": "00000000-0000-0000-0000-000000000001"} + if "training_parameters" in self.sql: + return { + "id": 7, + "key": "cadence", + "name_de": "K", + "name_en": "K", + "param_category": "physical", + "data_type": "integer", + "unit": None, + "validation_rules": {"min": 0, "max": 300}, + "source_field": None, + } + return None + + cur = Cur() + upsert_session_metrics_from_csv_mapped( + cur, + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + {"cadence": 90}, + None, + None, + ) + assert cur.asm_inserts == 1 + + @patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") def test_upsert_csv_skips_parameter_with_source_field(mock_schema): """Kein INSERT in activity_session_metrics für Spalten-Parameter (avg_hr → hr_avg)."""