Erste Version Platzhalter EAV #86

Merged
Lars merged 21 commits from develop into main 2026-04-17 21:52:14 +02:00
4 changed files with 214 additions and 21 deletions
Showing only changes of commit 1220ee54fb - Show all commits

View File

@ -621,16 +621,35 @@ def replace_activity_session_metrics(
return fetch_activity_session_metrics(cur, activity_log_id)
def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str) -> Dict[str, Any]:
def get_activity_session_logical_unit(
cur,
profile_id: str,
activity_log_id: str,
*,
use_form_training_context: bool = False,
form_training_category: Optional[str] = None,
form_training_type_id: Optional[int] = None,
) -> Dict[str, Any]:
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
row = cur.fetchone()
if not row or str(row["profile_id"]) != str(profile_id):
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
header = dict(row)
schema = resolve_activity_attribute_schema(
cur, header.get("training_category"), header.get("training_type_id")
)
if use_form_training_context:
cat = form_training_category
if isinstance(cat, str):
cat = cat.strip() or None
tid = form_training_type_id
else:
cat = header.get("training_category")
tid = header.get("training_type_id")
if tid is not None:
try:
tid = int(tid)
except (TypeError, ValueError):
tid = None
schema = resolve_activity_attribute_schema(cur, cat, tid)
metrics = fetch_activity_session_metrics(cur, activity_log_id)
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
return {

View File

@ -371,6 +371,25 @@ def get_activity_mappable_fields(session: dict = Depends(require_auth)):
return get_mappable_activity_field_catalog(cur, pid)
@router.get("/attribute-schema")
def get_activity_attribute_schema(
training_category: Optional[str] = Query(None),
training_type_id: Optional[int] = Query(None),
session: dict = Depends(require_auth),
):
"""
Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session
gleiche Logik wie resolve_activity_attribute_schema.
"""
from data_layer.activity_session_metrics import resolve_activity_attribute_schema
cat = (training_category or "").strip() or None
with get_db() as conn:
cur = get_cursor(conn)
schema = resolve_activity_attribute_schema(cur, cat, training_type_id)
return {"schema": schema}
@router.put("/{eid}")
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Update existing activity entry."""
@ -422,6 +441,12 @@ def replace_activity_metrics(
@router.get("/{eid}")
def get_activity_session(
eid: str,
use_form_schema: bool = Query(
False,
description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile",
),
training_category: Optional[str] = Query(None),
training_type_id: Optional[int] = Query(None),
session: dict = Depends(require_auth),
):
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
@ -435,7 +460,14 @@ def get_activity_session(
try:
with get_db() as conn:
cur = get_cursor(conn)
unit = get_activity_session_logical_unit(cur, pid, eid)
unit = get_activity_session_logical_unit(
cur,
pid,
eid,
use_form_training_context=use_form_schema,
form_training_category=training_category,
form_training_type_id=training_type_id,
)
except ActivitySessionMetricsError as err:
raise HTTPException(err.status_code, err.detail) from err
unit["header"] = serialize_dates(unit["header"])

View File

@ -554,6 +554,12 @@ export default function ActivityPage() {
const [categories, setCategories] = useState({}) // v9d: Training categories
const [sessionDetail, setSessionDetail] = useState(null)
const [metricDraft, setMetricDraft] = useState({})
/** Beim Wechsel Kategorie/Typ: Nutzerwerte für weiterhin vorhandene Schema-Keys nicht mit Server überschreiben */
const editSchemaKeysPrevRef = useRef(new Set())
const prevEditingIdRef = useRef(null)
const [manualSchema, setManualSchema] = useState(null)
const [manualMetricDraft, setManualMetricDraft] = useState({})
const manualSchemaKeysPrevRef = useRef(new Set())
const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false)
const [listLoadingMore, setListLoadingMore] = useState(false)
@ -639,18 +645,31 @@ export default function ActivityPage() {
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
}, [fetchMonthsChain])
useEffect(() => {
editSchemaKeysPrevRef.current = new Set()
}, [editing?.id])
useEffect(() => {
if (!editing?.id) {
setSessionDetail(null)
setMetricDraft({})
setSessionLoadError(null)
prevEditingIdRef.current = null
return
}
let cancelled = false
setSessionLoadError(null)
if (prevEditingIdRef.current !== editing.id) {
setSessionDetail(null)
prevEditingIdRef.current = editing.id
}
;(async () => {
try {
const d = await api.getActivitySession(editing.id)
const d = await api.getActivitySession(editing.id, {
useFormSchema: true,
training_category: editing.training_category,
training_type_id: editing.training_type_id,
})
if (!cancelled) setSessionDetail(d)
} catch (err) {
if (!cancelled) {
@ -660,25 +679,89 @@ export default function ActivityPage() {
}
})()
return () => { cancelled = true }
}, [editing?.id])
}, [editing?.id, editing?.training_category, editing?.training_type_id])
useEffect(() => {
if (!sessionDetail) {
setMetricDraft({})
return
}
const m = {}
for (const row of sessionDetail.metrics || []) {
m[row.key] = row.value
}
for (const s of sessionDetail.schema || []) {
if (!(s.key in m)) {
m[s.key] = s.data_type === 'boolean' ? false : ''
const newKeys = new Set((sessionDetail.schema || []).map((s) => s.key))
const oldKeys = editSchemaKeysPrevRef.current
setMetricDraft((prev) => {
const next = { ...prev }
for (const row of sessionDetail.metrics || []) {
const k = row.key
if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
continue
}
next[k] = row.value
}
}
setMetricDraft(m)
for (const s of sessionDetail.schema || []) {
if (!(s.key in next)) {
next[s.key] = s.data_type === 'boolean' ? false : ''
}
}
return next
})
editSchemaKeysPrevRef.current = newKeys
}, [sessionDetail])
useEffect(() => {
if (tab !== 'add') {
setManualSchema(null)
setManualMetricDraft({})
manualSchemaKeysPrevRef.current = new Set()
return
}
const tid = form.training_type_id
const cat = form.training_category
if (tid == null && (cat == null || cat === '')) {
setManualSchema(null)
setManualMetricDraft({})
manualSchemaKeysPrevRef.current = new Set()
return
}
let cancelled = false
;(async () => {
try {
const r = await api.getActivityAttributeSchema({
training_category: cat || undefined,
training_type_id: tid ?? undefined,
})
if (!cancelled) setManualSchema(Array.isArray(r.schema) ? r.schema : [])
} catch (err) {
console.error('attribute-schema:', err)
if (!cancelled) setManualSchema([])
}
})()
return () => { cancelled = true }
}, [tab, form.training_category, form.training_type_id])
useEffect(() => {
if (tab !== 'add' || !manualSchema) {
return
}
const newKeys = new Set(manualSchema.map((s) => s.key))
const oldKeys = manualSchemaKeysPrevRef.current
setManualMetricDraft((prev) => {
const next = { ...prev }
for (const s of manualSchema) {
const k = s.key
if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
continue
}
if (!(k in next)) {
next[k] = s.data_type === 'boolean' ? false : ''
}
}
return next
})
manualSchemaKeysPrevRef.current = newKeys
}, [tab, manualSchema])
const handleSave = async () => {
setSaving(true)
setError(null)
@ -698,7 +781,20 @@ export default function ActivityPage() {
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
payload.source = 'manual'
await api.createActivity(payload)
const created = await api.createActivity(payload)
if (manualSchema && manualSchema.length > 0 && created?.id) {
try {
const metrics = buildMetricsPayload(manualSchema, manualMetricDraft)
await api.putActivityMetrics(created.id, { metrics })
} catch (metErr) {
console.error(metErr)
setError(
metErr.message ||
'Eintrag gespeichert, aber Zusatzfelder konnten nicht gespeichert werden.',
)
setTimeout(() => setError(null), 8000)
}
}
setSaved(true)
await load()
await loadUsage() // Reload usage after save
@ -864,9 +960,23 @@ export default function ActivityPage() {
<span>Training eintragen</span>
{activityUsage && <UsageBadge {...activityUsage} />}
</div>
<EntryForm form={form} setForm={setForm}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={activityUsage}/>
<EntryForm
form={form}
setForm={setForm}
onSave={handleSave}
saveLabel={saved ? '✓ Gespeichert!' : 'Speichern'}
saving={saving}
error={error}
usage={activityUsage}
formExtras={
<SessionMetricsFields
schema={manualSchema}
metrics={[]}
values={manualMetricDraft}
setValues={setManualMetricDraft}
/>
}
/>
</div>
)}

View File

@ -352,7 +352,39 @@ export const api = {
adminDeleteTrainingTypeParameter: (id) =>
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
/**
* @param {string} id
* @param {{ useFormSchema?: boolean, training_category?: string | null, training_type_id?: number | null }} [opts]
*/
getActivitySession: (id, opts = {}) => {
const q = new URLSearchParams()
if (opts.useFormSchema) {
q.set('use_form_schema', 'true')
if (opts.training_category != null && opts.training_category !== '') {
q.set('training_category', String(opts.training_category))
}
if (opts.training_type_id != null && opts.training_type_id !== '') {
q.set('training_type_id', String(opts.training_type_id))
}
}
const qs = q.toString()
return req(`/activity/${encodeURIComponent(id)}${qs ? `?${qs}` : ''}`)
},
/**
* Attributprofil ohne Session (manuelle Erfassung / Vorschau).
* @param {{ training_category?: string | null, training_type_id?: number | null }} [params]
*/
getActivityAttributeSchema: (params = {}) => {
const q = new URLSearchParams()
if (params.training_category != null && params.training_category !== '') {
q.set('training_category', String(params.training_category))
}
if (params.training_type_id != null && params.training_type_id !== '') {
q.set('training_type_id', String(params.training_type_id))
}
const qs = q.toString()
return req(`/activity/attribute-schema${qs ? `?${qs}` : ''}`)
},
putActivityMetrics: (id, body) =>
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),