feat: Add update functionality for training category and type parameters
- Introduced new endpoints for updating training category and type parameters in the backend. - Added corresponding update functions in the frontend API utility. - Enhanced the Admin Activity Attribute Profiles page to support editing and saving changes for category and type parameters. - Implemented state management for editing parameters and improved error handling during updates.
This commit is contained in:
parent
cf7379b2f6
commit
196b6c5cf1
|
|
@ -0,0 +1,213 @@
|
|||
-- Migration 055: Seed training_category_parameter (all categories × parameters with activity_log source_field)
|
||||
-- + idempotent backfill activity_log → activity_session_metrics (EAV)
|
||||
-- Date: 2026-04-15
|
||||
-- SAFE: INSERT … ON CONFLICT DO NOTHING only; no DELETE/TRUNCATE on activity_log.
|
||||
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
||||
|
||||
--1) Jede in training_types vorkommende Kategorie erhält alle aktiven Parameter mit source_field (Spalte in activity_log).
|
||||
INSERT INTO training_category_parameter (
|
||||
training_category,
|
||||
training_parameter_id,
|
||||
sort_order,
|
||||
required,
|
||||
ui_group
|
||||
)
|
||||
SELECT
|
||||
tc.training_category,
|
||||
tp.id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY tc.training_category
|
||||
ORDER BY tp.category, tp.id
|
||||
),
|
||||
false,
|
||||
NULL
|
||||
FROM (
|
||||
SELECT DISTINCT category AS training_category
|
||||
FROM training_types
|
||||
WHERE category IS NOT NULL AND trim(category) <> ''
|
||||
) tc
|
||||
CROSS JOIN training_parameters tp
|
||||
WHERE tp.is_active = true
|
||||
AND tp.source_field IS NOT NULL
|
||||
AND trim(tp.source_field) <> ''
|
||||
ON CONFLICT (training_category, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- 2) Backfill: activity_log-Spalten → EAV (nur wenn noch keine Zeile existiert)
|
||||
|
||||
-- duration_min → integer
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT
|
||||
a.id,
|
||||
tp.id,
|
||||
NULL,
|
||||
ROUND(a.duration_min::numeric)::bigint,
|
||||
NULL,
|
||||
NULL,
|
||||
NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'duration_min' AND tp.is_active = true
|
||||
WHERE a.duration_min IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- distance_km → float
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.distance_km::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'distance_km' AND tp.is_active = true
|
||||
WHERE a.distance_km IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- kcal_active
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.kcal_active::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'kcal_active' AND tp.is_active = true
|
||||
WHERE a.kcal_active IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.kcal_resting::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'kcal_resting' AND tp.is_active = true
|
||||
WHERE a.kcal_resting IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- hr_avg / hr_max → keys avg_hr, max_hr
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.hr_avg::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'avg_hr' AND tp.is_active = true
|
||||
WHERE a.hr_avg IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, ROUND(a.hr_max::numeric)::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'max_hr' AND tp.is_active = true
|
||||
WHERE a.hr_max IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- rpe
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.rpe::bigint, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'rpe' AND tp.is_active = true
|
||||
WHERE a.rpe IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
-- min_hr (Spalte hr_min nach Migration 014)
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true
|
||||
WHERE a.hr_min IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true
|
||||
WHERE a.pace_min_per_km IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true
|
||||
WHERE a.cadence IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true
|
||||
WHERE a.avg_power IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true
|
||||
WHERE a.elevation_gain IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true
|
||||
WHERE a.temperature_celsius IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true
|
||||
WHERE a.humidity_percent IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true
|
||||
WHERE a.avg_hr_percent IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_session_metrics (
|
||||
activity_log_id, training_parameter_id,
|
||||
value_num, value_int, value_text, value_bool, updated_at
|
||||
)
|
||||
SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW()
|
||||
FROM activity_log a
|
||||
JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true
|
||||
WHERE a.kcal_per_km IS NOT NULL
|
||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 055: category parameter seed + EAV backfill from activity_log (no row deletes)';
|
||||
END $$;
|
||||
|
|
@ -35,18 +35,18 @@ logger = logging.getLogger(__name__)
|
|||
def list_activity(
|
||||
limit: int = Query(200, ge=1, le=50_000),
|
||||
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"),
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
# Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet).
|
||||
pid = str(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
profile = r2d(cur.fetchone())
|
||||
quality_filter = get_quality_filter_sql(profile)
|
||||
quality_filter = get_quality_filter_sql(profile or {})
|
||||
|
||||
if days is not None:
|
||||
cur.execute(
|
||||
|
|
@ -229,13 +229,23 @@ def get_activity_session(
|
|||
|
||||
|
||||
@router.get("/stats")
|
||||
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
def activity_stats(session: dict = Depends(require_auth)):
|
||||
"""Get activity statistics (last 30 entries)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
pid = str(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
profile = r2d(cur.fetchone())
|
||||
quality_filter = get_quality_filter_sql(profile or {})
|
||||
cur.execute(
|
||||
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s {quality_filter}
|
||||
ORDER BY date 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":{}}
|
||||
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ class TypeParameterCreate(BaseModel):
|
|||
ui_group: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class CategoryParameterUpdate(BaseModel):
|
||||
sort_order: Optional[int] = None
|
||||
required: Optional[bool] = None
|
||||
ui_group: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class TypeParameterUpdate(BaseModel):
|
||||
sort_order: Optional[int] = None
|
||||
required: Optional[bool] = None
|
||||
ui_group: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
@router.get("/training-category-parameters")
|
||||
def admin_list_category_parameters(
|
||||
category: Optional[str] = Query(None, description="Filter: training_types.category"),
|
||||
|
|
@ -91,6 +103,41 @@ def admin_add_category_parameter(
|
|||
return {"id": new_id}
|
||||
|
||||
|
||||
@router.put("/training-category-parameters/{link_id}")
|
||||
def admin_update_category_parameter(
|
||||
link_id: int,
|
||||
body: CategoryParameterUpdate,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
if not patch:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
cols: list[str] = []
|
||||
vals: list = []
|
||||
if "sort_order" in patch:
|
||||
cols.append("sort_order = %s")
|
||||
vals.append(patch["sort_order"])
|
||||
if "required" in patch:
|
||||
cols.append("required = %s")
|
||||
vals.append(patch["required"])
|
||||
if "ui_group" in patch:
|
||||
cols.append("ui_group = %s")
|
||||
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
|
||||
if not cols:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
vals.append(link_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
f"UPDATE training_category_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
||||
vals,
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": link_id}
|
||||
|
||||
|
||||
@router.delete("/training-category-parameters/{link_id}")
|
||||
def admin_delete_category_parameter(
|
||||
link_id: int,
|
||||
|
|
@ -167,6 +214,41 @@ def admin_add_type_parameter(
|
|||
return {"id": new_id}
|
||||
|
||||
|
||||
@router.put("/training-type-parameters/{link_id}")
|
||||
def admin_update_type_parameter(
|
||||
link_id: int,
|
||||
body: TypeParameterUpdate,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
if not patch:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
cols: list[str] = []
|
||||
vals: list = []
|
||||
if "sort_order" in patch:
|
||||
cols.append("sort_order = %s")
|
||||
vals.append(patch["sort_order"])
|
||||
if "required" in patch:
|
||||
cols.append("required = %s")
|
||||
vals.append(patch["required"])
|
||||
if "ui_group" in patch:
|
||||
cols.append("ui_group = %s")
|
||||
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
|
||||
if not cols:
|
||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
||||
vals.append(link_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
f"UPDATE training_type_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
||||
vals,
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": link_id}
|
||||
|
||||
|
||||
@router.delete("/training-type-parameters/{link_id}")
|
||||
def admin_delete_type_parameter(
|
||||
link_id: int,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -326,11 +326,15 @@ export const api = {
|
|||
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
|
||||
),
|
||||
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
|
||||
adminUpdateTrainingCategoryParameter: (id, d) =>
|
||||
req(`/admin/training-category-parameters/${id}`, jput(d)),
|
||||
adminDeleteTrainingCategoryParameter: (id) =>
|
||||
req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
|
||||
adminListTrainingTypeParameters: (trainingTypeId) =>
|
||||
req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
|
||||
adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
|
||||
adminUpdateTrainingTypeParameter: (id, d) =>
|
||||
req(`/admin/training-type-parameters/${id}`, jput(d)),
|
||||
adminDeleteTrainingTypeParameter: (id) =>
|
||||
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user