Erste Version - Universal CSV Importer für EAV und activity_log #85

Merged
Lars merged 17 commits from develop into main 2026-04-15 11:46:31 +02:00
3 changed files with 58 additions and 16 deletions
Showing only changes of commit 9fdb02ff8b - Show all commits

View File

@ -356,7 +356,7 @@ def replace_activity_session_metrics(
cur, row.get("training_category"), row.get("training_type_id")
)
by_key = {s["key"]: s for s in schema}
payload_keys = set()
payload_by_key: Dict[str, Dict[str, Any]] = {}
for item in metrics:
raw_k = item.get("parameter_key")
if raw_k is None or not str(raw_k).strip():
@ -364,11 +364,15 @@ def replace_activity_session_metrics(
k = str(raw_k).strip()
if k not in by_key:
raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}")
payload_keys.add(k)
payload_by_key[k] = item
for s in schema:
if s["required"] and s["key"] not in payload_keys:
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {s['key']}")
if not s["required"]:
continue
itk = s["key"]
hit = payload_by_key.get(itk)
if hit is None or hit.get("value") is None:
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
cur.execute(
"DELETE FROM activity_session_metrics WHERE activity_log_id = %s",
@ -378,9 +382,14 @@ def replace_activity_session_metrics(
for item in metrics:
k = str(item["parameter_key"]).strip()
spec = by_key[k]
val = item.get("value")
if val is None:
if spec["required"]:
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}")
continue
rules = _validation_rules_dict(spec["validation_rules"])
_validate_single_value(spec["data_type"], item.get("value"), rules)
vn, vi, vt, vb = _row_value_tuple(spec["data_type"], item["value"])
_validate_single_value(spec["data_type"], val, rules)
vn, vi, vt, vb = _row_value_tuple(spec["data_type"], val)
cur.execute(
"""
INSERT INTO activity_session_metrics (

View File

@ -64,7 +64,7 @@ def list_activity(
WHERE profile_id=%s
{quality_filter}
AND date >= (CURRENT_DATE - %s::integer)
ORDER BY date DESC, start_time DESC
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s OFFSET %s
""",
(pid, days, limit, offset),
@ -75,7 +75,7 @@ def list_activity(
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
ORDER BY date DESC, start_time DESC
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s OFFSET %s
""",
(pid, limit, offset),
@ -192,18 +192,30 @@ def activity_stats(
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {})
cur.execute(
f"SELECT COUNT(*)::bigint AS c FROM activity_log WHERE profile_id=%s {quality_filter}",
(pid,),
)
total_in_profile = int(cur.fetchone()["c"])
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s {quality_filter}
ORDER BY date DESC
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT 30
""",
(pid,),
)
rows = [r2d(r) for r in cur.fetchall()]
if not rows:
return {"count": 0, "total_kcal": 0, "total_min": 0, "by_type": {}}
return {
"count": 0,
"sample_size": 0,
"total_in_profile": total_in_profile,
"total_kcal": 0,
"total_min": 0,
"by_type": {},
}
total_kcal = sum(float(r.get("kcal_active") or 0) for r in rows)
total_min = sum(float(r.get("duration_min") or 0) for r in rows)
by_type = {}
@ -215,6 +227,8 @@ def activity_stats(
by_type[t]["min"] += float(r.get("duration_min") or 0)
return {
"count": len(rows),
"sample_size": len(rows),
"total_in_profile": total_in_profile,
"total_kcal": round(total_kcal),
"total_min": round(total_min),
"by_type": by_type,

View File

@ -66,6 +66,7 @@ function buildMetricsPayload(schema, draft) {
if (s.data_type === 'boolean') {
if (raw === '' || raw === null || raw === undefined) {
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
out.push({ parameter_key: s.key, value: null })
continue
}
out.push({ parameter_key: s.key, value: !!raw })
@ -73,6 +74,7 @@ function buildMetricsPayload(schema, draft) {
}
if (raw === '' || raw === null || raw === undefined) {
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
out.push({ parameter_key: s.key, value: null })
continue
}
let v = raw
@ -335,7 +337,14 @@ export default function ActivityPage() {
setListHasMore(false)
return
}
setEntries((prev) => [...prev, ...more])
const prev = entriesRef.current
const seen = new Set(prev.map((r) => r.id))
const add = more.filter((r) => r.id && !seen.has(r.id))
if (add.length === 0) {
setListHasMore(false)
return
}
setEntries((p) => [...p, ...add])
setListHasMore(more.length === n)
} finally {
setListLoadingMore(false)
@ -433,7 +442,10 @@ export default function ActivityPage() {
if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue
if (!(s.key in metricDraft)) continue
const raw = metricDraft[s.key]
if (raw === '' || raw === null || raw === undefined) continue
if (raw === '' || raw === null || raw === undefined) {
payload[col] = null
continue
}
let v = raw
if (s.data_type === 'integer') {
v = parseInt(String(raw), 10)
@ -506,12 +518,19 @@ export default function ActivityPage() {
</div>
{/* Übersicht */}
{stats && stats.count>0 && (
{stats && (stats.total_in_profile > 0 || stats.count > 0) && (
<div className="card section-gap">
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
<strong>{stats.total_in_profile ?? ''}</strong> Einträge im Profil (gleicher Filter wie diese Seite). Die Summen
Kcal/Stunden beziehen sich auf die <strong>neuesten {stats.sample_size ?? stats.count}</strong> Einträge (max.
30).
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
{[['Trainings',stats.count,'var(--text1)'],
['Kcal gesamt',Math.round(stats.total_kcal),'#EF9F27'],
['Stunden',Math.round(stats.total_min/60*10)/10,'#378ADD']].map(([l,v,c])=>(
{[
['Neueste (max. 30)', stats.count, 'var(--text1)'],
['Kcal (darin)', Math.round(stats.total_kcal), '#EF9F27'],
['Stunden (darin)', Math.round(stats.total_min / 60 * 10) / 10, '#378ADD'],
].map(([l, v, c]) => (
<div key={l} style={{flex:1,minWidth:80,background:'var(--surface2)',borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
<div style={{fontSize:18,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:10,color:'var(--text3)'}}>{l}</div>