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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Dict, List, Optional, Sequence
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ActivitySessionMetricsError(Exception):
|
class ActivitySessionMetricsError(Exception):
|
||||||
"""Raised by Layer 1; routers map to HTTP (404/400)."""
|
"""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)
|
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]]:
|
def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -297,6 +391,9 @@ def replace_activity_session_metrics(
|
||||||
(activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb),
|
(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)
|
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 routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
from quality_filter import get_quality_filter_sql
|
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)
|
# Evaluation import with error handling (Phase 1.2)
|
||||||
try:
|
try:
|
||||||
|
|
@ -27,9 +31,6 @@ except Exception as e:
|
||||||
EVALUATION_AVAILABLE = False
|
EVALUATION_AVAILABLE = False
|
||||||
evaluate_and_save_activity = None
|
evaluate_and_save_activity = None
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_activity(
|
def list_activity(
|
||||||
|
|
@ -98,13 +99,33 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
||||||
d = e.model_dump()
|
d = e.model_dump()
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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,
|
(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)
|
hr_avg,hr_max,distance_km,rpe,source,notes,
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
training_type_id,training_category,training_subcategory,created)
|
||||||
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||||
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
(
|
||||||
d['rpe'],d['source'],d['notes']))
|
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
|
# Phase 1.2: Auto-evaluation after INSERT
|
||||||
if EVALUATION_AVAILABLE:
|
if EVALUATION_AVAILABLE:
|
||||||
|
|
@ -127,6 +148,8 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
||||||
except Exception as eval_error:
|
except Exception as eval_error:
|
||||||
logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {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)
|
# Phase 2: Increment usage counter (always for new entries)
|
||||||
increment_feature_usage(pid, 'activity_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:
|
except Exception as eval_error:
|
||||||
logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {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}
|
return {"id":eid}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -241,7 +266,6 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None),
|
||||||
def replace_activity_metrics(
|
def replace_activity_metrics(
|
||||||
eid: str,
|
eid: str,
|
||||||
body: ActivityMetricsReplace,
|
body: ActivityMetricsReplace,
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -252,7 +276,7 @@ def replace_activity_metrics(
|
||||||
replace_activity_session_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]
|
payload = [m.model_dump() for m in body.metrics]
|
||||||
try:
|
try:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -267,7 +291,6 @@ def replace_activity_metrics(
|
||||||
@router.get("/{eid}")
|
@router.get("/{eid}")
|
||||||
def get_activity_session(
|
def get_activity_session(
|
||||||
eid: str,
|
eid: str,
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
|
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
|
||||||
|
|
@ -277,7 +300,7 @@ def get_activity_session(
|
||||||
)
|
)
|
||||||
from data_layer.utils import serialize_dates
|
from data_layer.utils import serialize_dates
|
||||||
|
|
||||||
pid = get_pid(x_profile_id)
|
pid = str(session["profile_id"])
|
||||||
try:
|
try:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,26 @@ const ACTIVITY_TYPES = [
|
||||||
'Cardio Dance','Geist & Körper','Sonstiges'
|
'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() {
|
function empty() {
|
||||||
return {
|
return {
|
||||||
date: dayjs().format('YYYY-MM-DD'),
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
|
@ -360,6 +380,26 @@ export default function ActivityPage() {
|
||||||
try {
|
try {
|
||||||
const payload = { ...editing }
|
const payload = { ...editing }
|
||||||
delete payload.id
|
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.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.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)
|
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)}`),
|
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
|
||||||
putActivityMetrics: (id, body) =>
|
putActivityMetrics: (id, body) =>
|
||||||
req(`/activity/${encodeURIComponent(id)}/metrics`, json(body)),
|
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
|
||||||
|
|
||||||
// Sleep Module (v9d Phase 2b)
|
// Sleep Module (v9d Phase 2b)
|
||||||
listSleep: (l=90) => req(`/sleep?limit=${l}`),
|
listSleep: (l=90) => req(`/sleep?limit=${l}`),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user