""" 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}