refactor: Update session metrics handling to improve EAV logic and filtering
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Revised the `upsert_session_metrics_from_csv_mapped` function to clarify EAV writing conditions, ensuring only relevant parameters are processed.
- Enhanced the `merge_column_backed_and_eav_metrics` function to exclude EAV rows for parameters not present in the schema, improving data integrity.
- Updated unit tests to reflect changes in EAV handling and ensure correct functionality when parameters are mapped or not mapped in the profile schema.
- Improved frontend logic to prevent duplicate display of metrics already handled in the entry form, enhancing user experience.
This commit is contained in:
Lars 2026-04-16 12:53:14 +02:00
parent 2a26e4fecf
commit 9d5e16455c
3 changed files with 95 additions and 24 deletions

View File

@ -252,14 +252,15 @@ def upsert_session_metrics_from_csv_mapped(
training_type_id: Optional[int],
) -> None:
"""
EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen).
EAV für Trainingsparameter aus CSV.
Kernfelder (Datum, Start, Distanz, HF, ) schreibt der Executor nach activity_log;
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys.
Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
Trainingstyp) vorkommen. CSV-Spalten-Mappings sind import-spezifisch und definieren **nicht** das
UI-/Auswertungs-Schema fehlende tcp/ttp-Zuordnung bedeutet: kein EAV für diesen Key (Werte ggf.
nur in ``activity_log``-Kernfeldern).
Ist ``training_parameters.source_field`` gesetzt, ist die zugehörige ``activity_log``-Spalte
kanonisch (wie beim Lesen in ``merge_column_backed_and_eav_metrics``) kein EAV-Schreiben,
auch wenn der Parameter-Key vom Registry-Schlüssel abweicht.
Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys.
Bei gesetztem ``training_parameters.source_field`` ist die Spalte kanonisch kein EAV-Schreiben.
"""
cur.execute(
"SELECT profile_id FROM activity_log WHERE id = %s",
@ -315,9 +316,11 @@ def merge_column_backed_and_eav_metrics(
eav_metrics: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Effektive Metrikliste: Pro Schema-Parameter mit ``source_field`` hat ``activity_log`` Vorrang, wenn
die Spalte befüllt und koerzierbar ist; sonst EAV, sonst Legacy-Spalte (EAV-primär). Eine Semantik
erscheint nur einmal konsistent mit CSV-EAV-Upsert (dort kein Schreiben bei gesetztem ``source_field``).
Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
Pro Schema-Parameter mit ``source_field`` hat ``activity_log`` Vorrang, wenn die Spalte befüllt und
koerzierbar ist; sonst EAV, sonst Legacy-Spalte. EAV-Zeilen zu Parametern, die nicht im Schema sind,
werden nicht ausgegeben (Darstellung und Auswertung folgen ausschließlich dem konfigurierten Profil).
"""
eav_by_key = {m["key"]: m for m in eav_metrics}
merged: List[Dict[str, Any]] = []
@ -374,11 +377,6 @@ def merge_column_backed_and_eav_metrics(
except (TypeError, ValueError):
pass
for m in eav_metrics:
if m["key"] in keys_handled:
continue
merged.append(dict(m))
merged.sort(key=lambda x: x["key"])
return merged

View File

@ -219,8 +219,7 @@ def test_enrich_sessions_batch(mock_resolve):
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[0]["session_metrics"] == []
assert sessions[1]["session_metrics"] == []
@ -274,7 +273,8 @@ def test_merge_falls_back_to_eav_when_column_empty():
assert out[0]["value"] == 99.0
def test_merge_keeps_eav_only_keys():
def test_merge_ignores_eav_when_parameter_not_in_schema():
"""Nur tcp/ttp-Schema zählt: verwaiste EAV-Zeilen erscheinen nicht in der effektiven Liste."""
schema = []
eav = [
{
@ -286,8 +286,7 @@ def test_merge_keeps_eav_only_keys():
}
]
out = merge_column_backed_and_eav_metrics({}, schema, eav)
assert len(out) == 1
assert out[0]["key"] == "custom_param"
assert out == []
def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
@ -377,3 +376,29 @@ def test_upsert_csv_writes_eav_when_no_source_field(mock_schema):
1,
)
assert cur.asm_inserts == 1
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
def test_upsert_csv_skips_eav_when_mapped_key_not_in_profile_schema(mock_resolve):
"""Import-Mapping allein legt kein EAV an — Key muss in tcp/ttp (resolve) vorkommen."""
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {"profile_id": "00000000-0000-0000-0000-000000000001"}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"stola": 12},
"cardio",
1,
)
assert cur.asm_inserts == 0

View File

@ -96,6 +96,16 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
'training_subcategory',
])
/** activity_log-Spalten, die bereits in EntryForm (Kopfzeile) bearbeitet werden — Profilfeld mit gleichem source_field nicht doppelt anzeigen. */
const ENTRY_FORM_ACTIVITY_LOG_COLUMNS = new Set([
'duration_min',
'kcal_active',
'hr_avg',
'hr_max',
'rpe',
'notes',
])
function empty() {
return {
date: dayjs().format('YYYY-MM-DD'),
@ -146,16 +156,35 @@ function buildMetricsPayload(schema, draft) {
function SessionMetricsFields({ schema, values, setValues, metrics }) {
const schemaList = Array.isArray(schema) ? schema : []
const headlineDuplicateKeys = new Set(
schemaList
.filter((s) => {
const sf = s.source_field != null ? String(s.source_field).trim() : ''
return sf && ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)
})
.map((s) => s.key),
)
const schemaForDisplay = schemaList.filter((s) => {
const sf = s.source_field != null ? String(s.source_field).trim() : ''
if (!sf) return true
return !ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)
})
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))
const schemaKeys = new Set(schemaForDisplay.map((s) => s.key))
const orphanMetrics = metricRows.filter(
(row) =>
row &&
row.key &&
!schemaKeys.has(row.key) &&
!headlineDuplicateKeys.has(row.key),
)
if (schemaList.length === 0 && orphanMetrics.length === 0) return null
if (schemaForDisplay.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) => (
{schemaForDisplay.map((s) => (
<div key={s.key} className="form-row">
<label className="form-label">
{s.name_de}
@ -624,7 +653,26 @@ export default function ActivityPage() {
: timePayloadFromInput(payload.end_time)
await api.updateActivity(editing.id, payload)
if (sessionDetail?.schema?.length > 0) {
const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
const draftForMetrics = { ...metricDraft }
for (const s of sessionDetail.schema) {
const sf = s.source_field != null ? String(s.source_field).trim() : ''
if (!sf || !ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)) continue
if (!(s.key in draftForMetrics)) continue
const rawCol = payload[sf] !== undefined ? payload[sf] : editing?.[sf]
if (rawCol === undefined) continue
if (s.data_type === 'boolean') {
draftForMetrics[s.key] = !!rawCol
} else if (s.data_type === 'integer') {
const n = parseInt(String(rawCol), 10)
draftForMetrics[s.key] = Number.isNaN(n) ? '' : n
} else if (s.data_type === 'float') {
const n = parseFloat(String(rawCol))
draftForMetrics[s.key] = Number.isNaN(n) ? '' : n
} else {
draftForMetrics[s.key] = rawCol == null ? '' : String(rawCol)
}
}
const metrics = buildMetricsPayload(sessionDetail.schema, draftForMetrics)
await api.putActivityMetrics(editing.id, { metrics })
}
setEditing(null)