""" Training Planning Endpoints for Shinkan Jinkendo Handles CRUD operations for training units and their exercises. """ from typing import Optional from datetime import date, time from fastapi import APIRouter, HTTPException, Depends, Query from db import get_db, get_cursor, r2d from auth import require_auth router = APIRouter(prefix="/api", tags=["training_planning"]) def _optional_positive_int(val, field_name: str) -> Optional[int]: if val is None or val == "": return None try: i = int(val) except (TypeError, ValueError): raise HTTPException(400, detail=f"Ungültige {field_name}") if i < 1: raise HTTPException(400, detail=f"Ungültige {field_name}") return i def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]): """Prüft, dass exercise_variant_id zur gewählten Übung gehört.""" if not variant_id: return if not exercise_id: raise HTTPException(400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt") cur.execute( "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s", (variant_id, exercise_id), ) if not cur.fetchone(): raise HTTPException(400, detail="Variante passt nicht zur gewählten Übung") # ── List Training Units ─────────────────────────────────────────────── @router.get("/training-units") def list_training_units( group_id: Optional[int] = Query(default=None), start_date: Optional[str] = Query(default=None), end_date: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), session=Depends(require_auth) ): """ List training units with optional filters. Filters: - group_id: Filter by training group - start_date: Filter from this date (YYYY-MM-DD) - end_date: Filter to this date (YYYY-MM-DD) - status: planned, completed, cancelled """ profile_id = session['profile_id'] role = session.get('role') with get_db() as conn: cur = get_cursor(conn) query = """ SELECT tu.*, tg.name as group_name, tg.weekday as group_weekday, c.name as club_name, p.name as trainer_name FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN clubs c ON tg.club_id = c.id LEFT JOIN profiles p ON tu.created_by = p.id """ where = [] params = [] # Access control: show only own units unless admin if role not in ['admin', 'superadmin']: where.append("(tu.created_by = %s OR tg.trainer_id = %s)") params.extend([profile_id, profile_id]) if group_id: where.append("tu.group_id = %s") params.append(group_id) if start_date: where.append("tu.planned_date >= %s") params.append(start_date) if end_date: where.append("tu.planned_date <= %s") params.append(end_date) if status: where.append("tu.status = %s") params.append(status) if where: query += " WHERE " + " AND ".join(where) query += " ORDER BY tu.planned_date DESC, tu.planned_time_start DESC" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] # ── Get Training Unit ───────────────────────────────────────────────── @router.get("/training-units/{unit_id}") def get_training_unit(unit_id: int, session=Depends(require_auth)): """Get training unit by ID with exercises.""" profile_id = session['profile_id'] role = session.get('role') with get_db() as conn: cur = get_cursor(conn) # Get training unit cur.execute(""" SELECT tu.*, tg.name as group_name, tg.weekday as group_weekday, tg.time_start as group_time_start, tg.time_end as group_time_end, tg.location as group_location, c.name as club_name, p.name as trainer_name FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN clubs c ON tg.club_id = c.id LEFT JOIN profiles p ON tu.created_by = p.id WHERE tu.id = %s """, (unit_id,)) unit = cur.fetchone() if not unit: raise HTTPException(404, "Trainingseinheit nicht gefunden") unit = r2d(unit) # Access control cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit['group_id'],)) group = cur.fetchone() if role not in ['admin', 'superadmin']: if unit['created_by'] != profile_id and (not group or group['trainer_id'] != profile_id): raise HTTPException(403, "Keine Berechtigung") # Get exercises cur.execute(""" SELECT tue.*, e.title as exercise_title, e.summary as exercise_summary, e.focus_area as exercise_focus_area, ev.variant_name as exercise_variant_name FROM training_unit_exercises tue LEFT JOIN exercises e ON tue.exercise_id = e.id LEFT JOIN exercise_variants ev ON tue.exercise_variant_id = ev.id WHERE tue.training_unit_id = %s ORDER BY tue.order_index """, (unit_id,)) unit['exercises'] = [r2d(r) for r in cur.fetchall()] return unit # ── Create Training Unit ────────────────────────────────────────────── @router.post("/training-units") def create_training_unit(data: dict, session=Depends(require_auth)): """Create new training unit.""" profile_id = session['profile_id'] role = session.get('role') group_id = data.get('group_id') planned_date = data.get('planned_date') if not group_id or not planned_date: raise HTTPException(400, "group_id und planned_date sind Pflichtfelder") with get_db() as conn: cur = get_cursor(conn) # Check group exists and access cur.execute(""" SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s """, (group_id,)) group = cur.fetchone() if not group: raise HTTPException(404, "Trainingsgruppe nicht gefunden") # Check permission co_trainers = group['co_trainer_ids'] or [] if role not in ['admin', 'superadmin', 'trainer']: raise HTTPException(403, "Nur Trainer dürfen Trainingseinheiten erstellen") if role not in ['admin', 'superadmin']: if group['trainer_id'] != profile_id and profile_id not in co_trainers: raise HTTPException(403, "Nur der zuständige Trainer darf für diese Gruppe planen") # Insert training unit cur.execute(""" INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, planned_focus, status, notes, trainer_notes, created_by ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( group_id, planned_date, data.get('planned_time_start'), data.get('planned_time_end'), data.get('planned_focus'), data.get('status', 'planned'), data.get('notes'), data.get('trainer_notes'), profile_id )) unit_id = cur.fetchone()['id'] exercises_in = data.get('exercises', []) slot = 0 for ex in exercises_in: eid = ex.get('exercise_id') if not eid: continue eid = int(eid) vid = _optional_positive_int(ex.get('exercise_variant_id'), 'exercise_variant_id') _validate_variant_for_exercise(cur, eid, vid) cur.execute(""" INSERT INTO training_unit_exercises ( training_unit_id, exercise_id, exercise_variant_id, order_index, planned_duration_min, notes ) VALUES (%s, %s, %s, %s, %s, %s) """, ( unit_id, eid, vid, slot, ex.get('planned_duration_min'), ex.get('notes') )) slot += 1 conn.commit() return get_training_unit(unit_id, session) # ── Update Training Unit ────────────────────────────────────────────── @router.put("/training-units/{unit_id}") def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)): """Update training unit.""" profile_id = session['profile_id'] role = session.get('role') with get_db() as conn: cur = get_cursor(conn) # Check existence and access cur.execute(""" SELECT tu.created_by, tu.group_id, tg.trainer_id, tg.co_trainer_ids FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id WHERE tu.id = %s """, (unit_id,)) unit = cur.fetchone() if not unit: raise HTTPException(404, "Trainingseinheit nicht gefunden") # Check permission co_trainers = unit['co_trainer_ids'] or [] if role not in ['admin', 'superadmin']: if unit['created_by'] != profile_id and unit['trainer_id'] != profile_id and profile_id not in co_trainers: raise HTTPException(403, "Keine Berechtigung") # Update training unit cur.execute(""" UPDATE training_units SET planned_date = %s, planned_time_start = %s, planned_time_end = %s, planned_focus = %s, actual_date = %s, actual_time_start = %s, actual_time_end = %s, attendance_count = %s, status = %s, notes = %s, trainer_notes = %s, updated_at = NOW() WHERE id = %s """, ( data.get('planned_date'), data.get('planned_time_start'), data.get('planned_time_end'), data.get('planned_focus'), data.get('actual_date'), data.get('actual_time_start'), data.get('actual_time_end'), data.get('attendance_count'), data.get('status'), data.get('notes'), data.get('trainer_notes'), unit_id )) # Update exercises if provided if 'exercises' in data: # Delete existing exercises cur.execute("DELETE FROM training_unit_exercises WHERE training_unit_id = %s", (unit_id,)) # Add new exercises exercises_in = data['exercises'] slot = 0 for ex in exercises_in: eid = ex.get('exercise_id') if not eid: continue eid = int(eid) vid = _optional_positive_int(ex.get('exercise_variant_id'), 'exercise_variant_id') _validate_variant_for_exercise(cur, eid, vid) cur.execute(""" INSERT INTO training_unit_exercises ( training_unit_id, exercise_id, exercise_variant_id, order_index, planned_duration_min, actual_duration_min, notes, modifications ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, ( unit_id, eid, vid, slot, ex.get('planned_duration_min'), ex.get('actual_duration_min'), ex.get('notes'), ex.get('modifications') )) slot += 1 conn.commit() return get_training_unit(unit_id, session) # ── Delete Training Unit ────────────────────────────────────────────── @router.delete("/training-units/{unit_id}") def delete_training_unit(unit_id: int, session=Depends(require_auth)): """Delete training unit.""" profile_id = session['profile_id'] role = session.get('role') with get_db() as conn: cur = get_cursor(conn) # Check existence and access cur.execute(""" SELECT created_by FROM training_units WHERE id = %s """, (unit_id,)) unit = cur.fetchone() if not unit: raise HTTPException(404, "Trainingseinheit nicht gefunden") # Only creator or admin can delete if role not in ['admin', 'superadmin'] and unit['created_by'] != profile_id: raise HTTPException(403, "Keine Berechtigung") # Delete (CASCADE handles exercises) cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) conn.commit() return {"ok": True} # ── Quick Create from Template ──────────────────────────────────────── @router.post("/training-units/quick-create") def quick_create_training_unit(data: dict, session=Depends(require_auth)): """ Quick create training unit with group defaults. Takes group_id and date, auto-fills time from group schedule. """ profile_id = session['profile_id'] group_id = data.get('group_id') planned_date = data.get('planned_date') if not group_id or not planned_date: raise HTTPException(400, "group_id und planned_date sind Pflichtfelder") with get_db() as conn: cur = get_cursor(conn) # Get group defaults cur.execute(""" SELECT weekday, time_start, time_end, trainer_id, co_trainer_ids FROM training_groups WHERE id = %s """, (group_id,)) group = cur.fetchone() if not group: raise HTTPException(404, "Trainingsgruppe nicht gefunden") # Check permission role = session.get('role') co_trainers = group['co_trainer_ids'] or [] if role not in ['admin', 'superadmin', 'trainer']: raise HTTPException(403, "Nur Trainer dürfen Trainingseinheiten erstellen") if role not in ['admin', 'superadmin']: if group['trainer_id'] != profile_id and profile_id not in co_trainers: raise HTTPException(403, "Keine Berechtigung für diese Gruppe") # Create with group defaults cur.execute(""" INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, status, created_by ) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, ( group_id, planned_date, group['time_start'], group['time_end'], 'planned', profile_id )) unit_id = cur.fetchone()['id'] conn.commit() return get_training_unit(unit_id, session)