refactor: Update session metrics handling to improve EAV logic and filtering
- 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:
parent
2a26e4fecf
commit
9d5e16455c
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user