Erste Version Platzhalter EAV #86
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user