feat: Enhance activity log handling and session metrics synchronization
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s

- Added a new function to synchronize session metrics with activity log entries, ensuring data consistency.
- Updated the create and update activity endpoints to call the synchronization function after inserting or modifying activity logs.
- Introduced a set of allowed keys for activity log payloads to streamline data handling in the frontend.
- Improved data coercion logic for various data types in the frontend to ensure accurate data submission.
This commit is contained in:
Lars 2026-04-14 12:53:35 +02:00
parent db9952525a
commit 3296dfca28
4 changed files with 174 additions and 14 deletions

View File

@ -5,9 +5,12 @@ See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any, Dict, List, Optional, Sequence
logger = logging.getLogger(__name__)
class ActivitySessionMetricsError(Exception):
"""Raised by Layer 1; routers map to HTTP (404/400)."""
@ -193,6 +196,97 @@ def _row_value_tuple(data_type: str, value: Any) -> tuple:
raise ValueError(data_type)
def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
"""Wert aus activity_log-Spalte in den Typ bringen, den training_parameters.data_type erwartet."""
if data_type == "integer":
if isinstance(raw, bool):
raise TypeError("boolean nicht als integer erlaubt")
return int(round(float(raw)))
if data_type == "float":
return float(raw)
if data_type == "string":
return str(raw) if raw is not None else ""
if data_type == "boolean":
if isinstance(raw, bool):
return raw
s = str(raw).strip().lower()
if s in ("true", "1", "t", "yes"):
return True
if s in ("false", "0", "f", "no", ""):
return False
raise TypeError(f"boolean-Koercion nicht möglich: {raw!r}")
raise ValueError(data_type)
def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None:
"""
EAV-Zeilen für alle Schema-Parameter mit gesetztem source_field aus der activity_log-Zeile
schreiben (Upsert) bzw. bei NULL in der Quellspalte löschen. Reine Layer-1-Logik; keine Router-Abhängigkeit.
Synchron mit Übergangsphase: activity_log bleibt kanonisch für klassische Spalten; EAV spiegelt dieselben
Werte für Profil/Platzhalter/Detail-API, ohne replace_activity_session_metrics aufzurufen.
"""
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):
return
header = dict(row)
schema = resolve_activity_attribute_schema(
cur, header.get("training_category"), header.get("training_type_id")
)
for spec in schema:
sf = spec.get("source_field")
if sf is None or (isinstance(sf, str) and not str(sf).strip()):
continue
col = str(sf).strip()
if col not in header:
continue
raw = header[col]
tid = spec["training_parameter_id"]
dt = spec["data_type"]
rules = _validation_rules_dict(spec["validation_rules"])
if raw is None:
cur.execute(
"""
DELETE FROM activity_session_metrics
WHERE activity_log_id = %s AND training_parameter_id = %s
""",
(activity_log_id, tid),
)
continue
try:
coerced = _coerce_raw_value_for_parameter(dt, raw)
_validate_single_value(dt, coerced, rules)
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
logger.warning(
"sync_column_backed_session_metrics: überspringe %s (Spalte %s): %s",
spec.get("key"),
col,
ex,
)
continue
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
cur.execute(
"""
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (activity_log_id, training_parameter_id)
DO UPDATE SET
value_num = EXCLUDED.value_num,
value_int = EXCLUDED.value_int,
value_text = EXCLUDED.value_text,
value_bool = EXCLUDED.value_bool,
updated_at = NOW()
""",
(activity_log_id, tid, vn, vi, vt, vb),
)
def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]:
cur.execute(
"""
@ -297,6 +391,9 @@ def replace_activity_session_metrics(
(activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb),
)
# Übergang: Spalten in activity_log sind maßgeblich — EAV für alle source_field-Parameter angleichen
sync_column_backed_session_metrics(cur, profile_id, activity_log_id)
return fetch_activity_session_metrics(cur, activity_log_id)

View File

@ -17,6 +17,10 @@ from models import ActivityEntry, ActivityMetricsReplace
from routers.profiles import get_pid
from feature_logger import log_feature_usage
from quality_filter import get_quality_filter_sql
from data_layer.activity_session_metrics import sync_column_backed_session_metrics
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
# Evaluation import with error handling (Phase 1.2)
try:
@ -27,9 +31,6 @@ except Exception as e:
EVALUATION_AVAILABLE = False
evaluate_and_save_activity = None
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
@router.get("")
def list_activity(
@ -98,13 +99,33 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
d = e.model_dump()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""INSERT INTO activity_log
cur.execute(
"""INSERT INTO activity_log
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
hr_avg,hr_max,distance_km,rpe,source,notes,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
d['rpe'],d['source'],d['notes']))
hr_avg,hr_max,distance_km,rpe,source,notes,
training_type_id,training_category,training_subcategory,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(
eid,
pid,
d["date"],
d["start_time"],
d["end_time"],
d["activity_type"],
d["duration_min"],
d["kcal_active"],
d["kcal_resting"],
d["hr_avg"],
d["hr_max"],
d["distance_km"],
d["rpe"],
d["source"],
d["notes"],
d.get("training_type_id"),
d.get("training_category"),
d.get("training_subcategory"),
),
)
# Phase 1.2: Auto-evaluation after INSERT
if EVALUATION_AVAILABLE:
@ -127,6 +148,8 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
except Exception as eval_error:
logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}")
sync_column_backed_session_metrics(cur, str(pid), eid)
# Phase 2: Increment usage counter (always for new entries)
increment_feature_usage(pid, 'activity_entries')
@ -224,6 +247,8 @@ def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Head
except Exception as eval_error:
logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}")
sync_column_backed_session_metrics(cur, str(pid), eid)
return {"id":eid}
@ -241,7 +266,6 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None),
def replace_activity_metrics(
eid: str,
body: ActivityMetricsReplace,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""
@ -252,7 +276,7 @@ def replace_activity_metrics(
replace_activity_session_metrics,
)
pid = get_pid(x_profile_id)
pid = str(session["profile_id"])
payload = [m.model_dump() for m in body.metrics]
try:
with get_db() as conn:
@ -267,7 +291,6 @@ def replace_activity_metrics(
@router.get("/{eid}")
def get_activity_session(
eid: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
@ -277,7 +300,7 @@ def get_activity_session(
)
from data_layer.utils import serialize_dates
pid = get_pid(x_profile_id)
pid = str(session["profile_id"])
try:
with get_db() as conn:
cur = get_cursor(conn)

View File

@ -15,6 +15,26 @@ const ACTIVITY_TYPES = [
'Cardio Dance','Geist & Körper','Sonstiges'
]
/** Spalten, die mit ActivityEntry / UPDATE activity_log geschrieben werden dürfen (Übergang: Profilfelder → Kopfzeile). */
const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
'date',
'start_time',
'end_time',
'activity_type',
'duration_min',
'kcal_active',
'kcal_resting',
'hr_avg',
'hr_max',
'distance_km',
'rpe',
'source',
'notes',
'training_type_id',
'training_category',
'training_subcategory',
])
function empty() {
return {
date: dayjs().format('YYYY-MM-DD'),
@ -360,6 +380,26 @@ export default function ActivityPage() {
try {
const payload = { ...editing }
delete payload.id
for (const s of sessionDetail?.schema || []) {
const col = s.source_field
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
let v = raw
if (s.data_type === 'integer') {
v = parseInt(String(raw), 10)
if (Number.isNaN(v)) continue
} else if (s.data_type === 'float') {
v = parseFloat(String(raw))
if (Number.isNaN(v)) continue
} else if (s.data_type === 'boolean') {
v = !!raw
} else {
v = String(raw)
}
payload[col] = v
}
if (payload.duration_min !== '' && payload.duration_min != null) payload.duration_min = parseFloat(payload.duration_min)
if (payload.kcal_active !== '' && payload.kcal_active != null) payload.kcal_active = parseFloat(payload.kcal_active)
if (payload.hr_avg !== '' && payload.hr_avg != null) payload.hr_avg = parseFloat(payload.hr_avg)

View File

@ -340,7 +340,7 @@ export const api = {
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
putActivityMetrics: (id, body) =>
req(`/activity/${encodeURIComponent(id)}/metrics`, json(body)),
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
// Sleep Module (v9d Phase 2b)
listSleep: (l=90) => req(`/sleep?limit=${l}`),