feat: Enhance activity log handling and session metrics synchronization
- 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:
parent
db9952525a
commit
3296dfca28
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user