shinkan-jinkendo/backend/routers/training_planning.py
Lars 7f156ba085
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 4s
Test Suite / playwright-tests (push) Failing after 14s
feat: Training Planning (core feature) complete
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
2026-04-22 16:54:34 +02:00

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)