- Introduced Activity Session Metrics for enhanced tracking of session data. - Updated backend to support new API endpoints for managing session metrics. - Added new Pydantic models for activity metrics and replaced metrics functionality. - Enhanced data layer to include session metrics in recent training session data. - Updated documentation to reflect changes in session metrics handling.
216 lines
7.1 KiB
Python
216 lines
7.1 KiB
Python
"""
|
|
Admin: training_parameters catalog (EAV keys for activity session metrics).
|
|
|
|
Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
|
"""
|
|
import re
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from psycopg2 import errors as pg_errors
|
|
from psycopg2.extras import Json
|
|
|
|
from auth import require_admin
|
|
from db import get_db, get_cursor, r2d
|
|
|
|
router = APIRouter(prefix="/api/admin/training-parameters", tags=["admin", "training-parameters"])
|
|
|
|
KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
|
|
|
|
PARAM_CATEGORY = {"physical", "physiological", "subjective", "environmental", "performance"}
|
|
DATA_TYPES = {"integer", "float", "string", "boolean"}
|
|
|
|
|
|
class TrainingParameterCreate(BaseModel):
|
|
key: str = Field(..., min_length=1, max_length=50)
|
|
name_de: str = Field(..., min_length=1, max_length=100)
|
|
name_en: str = Field(..., min_length=1, max_length=100)
|
|
category: str = Field(..., max_length=50)
|
|
data_type: str = Field(..., max_length=20)
|
|
unit: Optional[str] = Field(None, max_length=20)
|
|
description_de: Optional[str] = None
|
|
description_en: Optional[str] = None
|
|
source_field: Optional[str] = Field(None, max_length=100)
|
|
validation_rules: Optional[dict] = None
|
|
is_active: bool = True
|
|
|
|
|
|
class TrainingParameterUpdate(BaseModel):
|
|
name_de: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
name_en: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
category: Optional[str] = Field(None, max_length=50)
|
|
data_type: Optional[str] = Field(None, max_length=20)
|
|
unit: Optional[str] = Field(None, max_length=20)
|
|
description_de: Optional[str] = None
|
|
description_en: Optional[str] = None
|
|
source_field: Optional[str] = Field(None, max_length=100)
|
|
validation_rules: Optional[dict] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
def _norm_key(key: str) -> str:
|
|
k = key.strip().lower()
|
|
if not KEY_PATTERN.match(k):
|
|
raise HTTPException(
|
|
400,
|
|
"Ungültiger key: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.",
|
|
)
|
|
return k
|
|
|
|
|
|
def _validate_category(cat: str) -> str:
|
|
c = cat.strip()
|
|
if c not in PARAM_CATEGORY:
|
|
raise HTTPException(400, f"category muss einer von {sorted(PARAM_CATEGORY)} sein")
|
|
return c
|
|
|
|
|
|
def _validate_data_type(dt: str) -> str:
|
|
d = dt.strip().lower()
|
|
if d not in DATA_TYPES:
|
|
raise HTTPException(400, f"data_type muss einer von {sorted(DATA_TYPES)} sein")
|
|
return d
|
|
|
|
|
|
@router.get("")
|
|
def admin_list_training_parameters(
|
|
include_inactive: bool = Query(False),
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
if include_inactive:
|
|
cur.execute(
|
|
"""
|
|
SELECT * FROM training_parameters
|
|
ORDER BY category, key
|
|
"""
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
SELECT * FROM training_parameters
|
|
WHERE is_active = true
|
|
ORDER BY category, key
|
|
"""
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.post("")
|
|
def admin_create_training_parameter(
|
|
body: TrainingParameterCreate,
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
key = _norm_key(body.key)
|
|
cat = _validate_category(body.category)
|
|
dt = _validate_data_type(body.data_type)
|
|
rules = body.validation_rules if body.validation_rules is not None else {}
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
try:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_parameters (
|
|
key, name_de, name_en, category, data_type, unit,
|
|
description_de, description_en, source_field, validation_rules, is_active
|
|
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
key,
|
|
body.name_de.strip(),
|
|
body.name_en.strip(),
|
|
cat,
|
|
dt,
|
|
body.unit.strip() if body.unit else None,
|
|
body.description_de,
|
|
body.description_en,
|
|
body.source_field.strip() if body.source_field else None,
|
|
Json(rules),
|
|
body.is_active,
|
|
),
|
|
)
|
|
new_id = cur.fetchone()["id"]
|
|
conn.commit()
|
|
except pg_errors.UniqueViolation:
|
|
conn.rollback()
|
|
raise HTTPException(409, "Parameter-key existiert bereits") from None
|
|
return {"id": new_id, "key": key}
|
|
|
|
|
|
@router.put("/{param_id}")
|
|
def admin_update_training_parameter(
|
|
param_id: int,
|
|
body: TrainingParameterUpdate,
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
cols: list[str] = []
|
|
vals: list[Any] = []
|
|
|
|
if body.name_de is not None:
|
|
cols.append("name_de = %s")
|
|
vals.append(body.name_de.strip())
|
|
if body.name_en is not None:
|
|
cols.append("name_en = %s")
|
|
vals.append(body.name_en.strip())
|
|
if body.category is not None:
|
|
cols.append("category = %s")
|
|
vals.append(_validate_category(body.category))
|
|
if body.data_type is not None:
|
|
cols.append("data_type = %s")
|
|
vals.append(_validate_data_type(body.data_type))
|
|
if body.unit is not None:
|
|
cols.append("unit = %s")
|
|
vals.append(body.unit.strip() or None)
|
|
if body.description_de is not None:
|
|
cols.append("description_de = %s")
|
|
vals.append(body.description_de)
|
|
if body.description_en is not None:
|
|
cols.append("description_en = %s")
|
|
vals.append(body.description_en)
|
|
if body.source_field is not None:
|
|
cols.append("source_field = %s")
|
|
vals.append(body.source_field.strip() or None)
|
|
if body.validation_rules is not None:
|
|
cols.append("validation_rules = %s")
|
|
vals.append(Json(body.validation_rules))
|
|
if body.is_active is not None:
|
|
cols.append("is_active = %s")
|
|
vals.append(body.is_active)
|
|
|
|
if not cols:
|
|
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
|
|
|
vals.append(param_id)
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
f"UPDATE training_parameters SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
|
vals,
|
|
)
|
|
if not cur.fetchone():
|
|
raise HTTPException(404, "Parameter nicht gefunden")
|
|
conn.commit()
|
|
return {"ok": True, "id": param_id}
|
|
|
|
|
|
@router.delete("/{param_id}")
|
|
def admin_deactivate_training_parameter(
|
|
param_id: int,
|
|
session: dict = Depends(require_admin),
|
|
):
|
|
"""Soft-delete: is_active = false (FK von session_metrics verhindert hartes Löschen)."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"UPDATE training_parameters SET is_active = false WHERE id = %s RETURNING id",
|
|
(param_id,),
|
|
)
|
|
if not cur.fetchone():
|
|
raise HTTPException(404, "Parameter nicht gefunden")
|
|
conn.commit()
|
|
return {"ok": True, "id": param_id}
|