feat: Add update functionality for training category and type parameters
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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:
Lars 2026-04-14 12:26:52 +02:00
parent cf7379b2f6
commit 196b6c5cf1
5 changed files with 971 additions and 344 deletions

View File

@ -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 $$;

View File

@ -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)

View File

@ -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

View File

@ -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' }),