Backend: - Created migration 006_training_planning.sql - training_units table (planned vs actual, status, notes) - training_unit_exercises M:N (order, duration, modifications) - Created routers/training_planning.py with full CRUD - List with filters (group, date range, status) - Get detail with exercises - Create/Update/Delete with access control - Quick create endpoint (auto-fills from group defaults) - Permission: trainer for own groups, admin for all - Registered training_planning router in main.py Frontend: - Complete TrainingPlanningPage with full planning workflow - Group selector + date range picker - List view with status badges (planned/completed/cancelled) - Create/Edit modal with exercise management - Drag to reorder exercises (▲▼) - Quick create button (one-click with group defaults) - Durchführung section (actual date/time, attendance, status) - Public notes + trainer-only notes - Updated api.js with deleteTrainingUnit and quickCreateTrainingUnit - Added /planning route to App.jsx - Navigation already configured (Calendar icon) This is the CORE feature - trainers can now plan and document training sessions. Next: Admin routes, role system refinement, testing
404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""
|
|
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"])
|
|
|
|
|
|
# ── 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
|
|
FROM training_unit_exercises tue
|
|
LEFT JOIN exercises e ON tue.exercise_id = e.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']
|
|
|
|
# Add exercises if provided
|
|
exercises = data.get('exercises', [])
|
|
for idx, ex in enumerate(exercises):
|
|
cur.execute("""
|
|
INSERT INTO training_unit_exercises (
|
|
training_unit_id, exercise_id, order_index,
|
|
planned_duration_min, notes
|
|
) VALUES (%s, %s, %s, %s, %s)
|
|
""", (
|
|
unit_id,
|
|
ex.get('exercise_id'),
|
|
idx,
|
|
ex.get('planned_duration_min'),
|
|
ex.get('notes')
|
|
))
|
|
|
|
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 = data['exercises']
|
|
for idx, ex in enumerate(exercises):
|
|
cur.execute("""
|
|
INSERT INTO training_unit_exercises (
|
|
training_unit_id, exercise_id, order_index,
|
|
planned_duration_min, actual_duration_min,
|
|
notes, modifications
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
""", (
|
|
unit_id,
|
|
ex.get('exercise_id'),
|
|
idx,
|
|
ex.get('planned_duration_min'),
|
|
ex.get('actual_duration_min'),
|
|
ex.get('notes'),
|
|
ex.get('modifications')
|
|
))
|
|
|
|
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)
|