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], training_type_id: Optional[int],
) -> None: ) -> 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; Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys. 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 Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys.
kanonisch (wie beim Lesen in ``merge_column_backed_and_eav_metrics``) kein EAV-Schreiben, Bei gesetztem ``training_parameters.source_field`` ist die Spalte kanonisch kein EAV-Schreiben.
auch wenn der Parameter-Key vom Registry-Schlüssel abweicht.
""" """
cur.execute( cur.execute(
"SELECT profile_id FROM activity_log WHERE id = %s", "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]], eav_metrics: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Effektive Metrikliste: Pro Schema-Parameter mit ``source_field`` hat ``activity_log`` Vorrang, wenn Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
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``). 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} eav_by_key = {m["key"]: m for m in eav_metrics}
merged: List[Dict[str, Any]] = [] merged: List[Dict[str, Any]] = []
@ -374,11 +377,6 @@ def merge_column_backed_and_eav_metrics(
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
for m in eav_metrics:
if m["key"] in keys_handled:
continue
merged.append(dict(m))
merged.sort(key=lambda x: x["key"]) merged.sort(key=lambda x: x["key"])
return merged return merged

View File

@ -219,8 +219,7 @@ def test_enrich_sessions_batch(mock_resolve):
sessions = [{"id": aid}, {"id": bid}] sessions = [{"id": aid}, {"id": bid}]
enrich_sessions_with_metrics(_Cur(), sessions) enrich_sessions_with_metrics(_Cur(), sessions)
assert sessions[0]["session_metrics"][0]["value"] == 7 assert sessions[0]["session_metrics"] == []
assert sessions[0]["session_metrics"][0]["key"] == "rpe"
assert sessions[1]["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 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 = [] schema = []
eav = [ eav = [
{ {
@ -286,8 +286,7 @@ def test_merge_keeps_eav_only_keys():
} }
] ]
out = merge_column_backed_and_eav_metrics({}, schema, eav) out = merge_column_backed_and_eav_metrics({}, schema, eav)
assert len(out) == 1 assert out == []
assert out[0]["key"] == "custom_param"
def test_merge_eav_primary_falls_back_to_legacy_hr_min_column(): 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, 1,
) )
assert cur.asm_inserts == 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', '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() { function empty() {
return { return {
date: dayjs().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
@ -146,16 +156,35 @@ function buildMetricsPayload(schema, draft) {
function SessionMetricsFields({ schema, values, setValues, metrics }) { function SessionMetricsFields({ schema, values, setValues, metrics }) {
const schemaList = Array.isArray(schema) ? schema : [] 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 metricRows = Array.isArray(metrics) ? metrics : []
const schemaKeys = new Set(schemaList.map((s) => s.key)) const schemaKeys = new Set(schemaForDisplay.map((s) => s.key))
const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.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 })) const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
return ( return (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}> <div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div> <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"> <div key={s.key} className="form-row">
<label className="form-label"> <label className="form-label">
{s.name_de} {s.name_de}
@ -624,7 +653,26 @@ export default function ActivityPage() {
: timePayloadFromInput(payload.end_time) : timePayloadFromInput(payload.end_time)
await api.updateActivity(editing.id, payload) await api.updateActivity(editing.id, payload)
if (sessionDetail?.schema?.length > 0) { 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 }) await api.putActivityMetrics(editing.id, { metrics })
} }
setEditing(null) setEditing(null)