feat: enhance role permissions and UI for clubs and training planning
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m55s

- Updated role permissions to allow trainers and users to create clubs and training groups.
- Modified database insertion logic to reflect the correct role for trainers during registration.
- Enhanced frontend components to display appropriate messages and buttons based on user roles.
- Improved user guidance in the Clubs and Training Planning pages, emphasizing the need for clubs and groups before planning training sessions.
This commit is contained in:
Lars 2026-04-28 19:46:09 +02:00
parent 1f2a4595cd
commit 69b26fc928
5 changed files with 136 additions and 50 deletions

View File

@ -245,7 +245,7 @@ async def register(req: RegisterRequest, request: Request):
id, name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
) VALUES (%s, %s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
""", (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends))
# Send verification email

View File

@ -82,10 +82,10 @@ def get_club(club_id: int, session=Depends(require_auth)):
# ── Create Club ───────────────────────────────────────────────────────
@router.post("/clubs")
def create_club(data: dict, session=Depends(require_auth)):
"""Create new club (admin only)."""
"""Create new club (Admin oder Trainer — MVP ohne separates Vereins-Onboarding)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Vereine erstellen")
if role not in ['admin', 'superadmin', 'trainer', 'user']:
raise HTTPException(403, "Keine Berechtigung, Vereine anzulegen")
name = data.get('name')
if not name:
@ -395,10 +395,11 @@ def get_training_group(group_id: int, session=Depends(require_auth)):
# ── Create Training Group ─────────────────────────────────────────────
@router.post("/groups")
def create_training_group(data: dict, session=Depends(require_auth)):
"""Create new training group (admin or trainer)."""
"""Create new training group (admin, trainer oder normaler Nutzer mit Vereinsbezug)."""
profile_id = session["profile_id"]
role = session.get('role')
if role not in ['admin', 'superadmin', 'trainer']:
raise HTTPException(403, "Nur Admins und Trainer dürfen Gruppen erstellen")
if role not in ['admin', 'superadmin', 'trainer', 'user']:
raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen")
club_id = data.get('club_id')
name = data.get('name')
@ -406,6 +407,10 @@ def create_training_group(data: dict, session=Depends(require_auth)):
if not club_id or not name:
raise HTTPException(400, "club_id und name sind Pflichtfelder")
trainer_id = data.get('trainer_id')
if trainer_id in (None, "", 0) and role in ("trainer", "user"):
trainer_id = profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -436,7 +441,7 @@ def create_training_group(data: dict, session=Depends(require_auth)):
data.get('time_start'),
data.get('time_end'),
data.get('location'),
data.get('trainer_id'),
trainer_id,
data.get('co_trainer_ids'),
data.get('status', 'active')
))

View File

@ -14,6 +14,11 @@ from auth import require_auth
router = APIRouter(prefix="/api", tags=["training_planning"])
def _has_planning_role(role: Optional[str]) -> bool:
"""Kann Trainingseinheiten/Vorlagen anlegen (bis Governance: auch einfacher Account)."""
return role in ("admin", "superadmin", "trainer", "user")
def _optional_positive_int(val, field_name: str) -> Optional[int]:
if val is None or val == "":
return None
@ -50,7 +55,7 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
if not group:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
co_trainers = group["co_trainer_ids"] or []
if role not in ["admin", "superadmin", "trainer"]:
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="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:
@ -381,7 +386,7 @@ def get_training_plan_template(template_id: int, session=Depends(require_auth)):
def create_training_plan_template(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
if role not in ["admin", "superadmin", "trainer"]:
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen")
name = (data.get("name") or "").strip()
if not name:
@ -787,7 +792,7 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
role = session.get("role")
co_trainers = group["co_trainer_ids"] or []
if role not in ["admin", "superadmin", "trainer"]:
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
if role not in ["admin", "superadmin"]:

View File

@ -18,6 +18,7 @@ function ClubsPage() {
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isTrainer = user?.role === 'trainer' || isAdmin
const canCreateClub = ['admin', 'superadmin', 'trainer', 'user'].includes(user?.role)
useEffect(() => {
loadData()
@ -61,7 +62,7 @@ function ClubsPage() {
time_start: '',
time_end: '',
location: '',
trainer_id: '',
trainer_id: user?.id ?? '',
co_trainer_ids: [],
status: 'active'
})
@ -144,7 +145,11 @@ function ClubsPage() {
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Vereinsverwaltung</h1>
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
</p>
{/* Tabs */}
<div style={{
@ -179,7 +184,7 @@ function ClubsPage() {
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2>Vereine</h2>
{isAdmin && (
{canCreateClub && (
<button className="btn btn-primary" onClick={() => handleCreate('club')}>
+ Neuer Verein
</button>
@ -188,9 +193,17 @@ function ClubsPage() {
{clubs.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Vereine gefunden
<p style={{ color: 'var(--text2)', textAlign: 'center', marginBottom: canCreateClub ? '1rem' : 0 }}>
Noch kein Verein angelegt.
{canCreateClub
? ' Nutze „+ Neuer Verein“ — ein Name reicht zum Start.'
: ' Bitte einen Administrator oder Support um Anlage.'}
</p>
{canCreateClub && (
<p style={{ color: 'var(--text2)', textAlign: 'center', fontSize: '0.9rem' }}>
Danach im Tab Trainingsgruppen eine Gruppe diesem Verein zuordnen; Details sind optional.
</p>
)}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
@ -328,7 +341,7 @@ function ClubsPage() {
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2>Trainingsgruppen</h2>
{isTrainer && (
{canCreateClub && (
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
+ Neue Gruppe
</button>

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
@ -481,7 +482,32 @@ function TrainingPlanningPage() {
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Trainingsplanung</h1>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}>
Wähle eine Trainingsgruppe, lege dann Termine mit Inhalt (Abschnitte und Übungen) an ein Plan entsteht aus einer oder mehreren{' '}
<strong>Trainingseinheiten</strong> im gewählten Zeitraum.
</p>
{!loading && groups.length === 0 && (
<div
className="card"
style={{
marginBottom: '1.25rem',
borderLeft: '4px solid var(--accent)',
padding: '1rem 1.25rem'
}}
>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.5rem' }}>Erst Verein & Gruppe anlegen</h2>
<p style={{ color: 'var(--text2)', marginBottom: '0.85rem', lineHeight: 1.5 }}>
Ohne Trainingsgruppe kann hier nichts gebucht werden. Unter <strong>Vereine</strong> legst du einen Verein an
(kurzer Name genügt), optional eine Sparte, dann eine <strong>Trainingsgruppe</strong>. Wochentage, feste Zeiten oder
Eigenschaften sind optional und kannst du später ergänzen.
</p>
<Link to="/clubs" className="btn btn-primary" style={{ textDecoration: 'none' }}>
Zu Vereinen & Trainingsgruppen
</Link>
</div>
)}
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div
@ -545,56 +571,93 @@ function TrainingPlanningPage() {
</p>
</div>
)}
</div>
{selectedGroupId && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '1.5rem',
alignItems: 'center'
marginTop: '1.25rem',
paddingTop: '1rem',
borderTop: '1px solid var(--border, rgba(0,0,0,0.08))'
}}
>
<button className="btn btn-primary" onClick={handleCreate}>
+ Neue Trainingseinheit
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<label className="form-label" style={{ marginBottom: 0 }}>
Schnell (+ optional Vorlage):
</label>
<select
className="form-input"
style={{ minWidth: '200px', marginBottom: 0 }}
value={quickTemplateId}
onChange={(e) => setQuickTemplateId(e.target.value)}
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.75rem' }}>
<strong>Plan anlegen:</strong> neue Trainingseinheit mit Datum, Zeit und Ablauf oder schnell nur mit Datum (Zeiten aus der Gruppe).
{!selectedGroupId && (
<span style={{ display: 'block', marginTop: '0.35rem' }}>
Wähle oben eine Trainingsgruppe, um die Schaltflächen zu aktivieren.
</span>
)}
{groups.length === 0 && (
<span style={{ display: 'block', marginTop: '0.35rem' }}>
Es gibt noch keine aktive Trainingsgruppe unter{' '}
<Link to="/clubs">
Vereine
</Link>{' '}
anlegen oder aktivieren.
</span>
)}
</p>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
alignItems: 'center'
}}
>
<button
type="button"
className="btn btn-primary"
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
onClick={handleCreate}
>
<option value="">Standard (leer)</option>
{planTemplates.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` (${t.sections_count} Abschn.)` : ''}
</option>
))}
</select>
<button className="btn btn-secondary" onClick={handleQuickCreate}>
Schnell erstellen
+ Neue Trainingseinheit planen
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<label className="form-label" style={{ marginBottom: 0 }}>
Schnell (+ optional Vorlage):
</label>
<select
className="form-input"
style={{ minWidth: '180px', marginBottom: 0 }}
value={quickTemplateId}
onChange={(e) => setQuickTemplateId(e.target.value)}
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst Trainingsgruppe wählen' : undefined}
>
<option value="">Standard (leer)</option>
{planTemplates.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` (${t.sections_count} Abschn.)` : ''}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
onClick={handleQuickCreate}
>
Schnell erstellen
</button>
</div>
</div>
</div>
)}
</div>
{!selectedGroupId ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Bitte wähle eine Trainingsgruppe aus
Wähle oben eine Trainingsgruppe danach kannst du mit <strong>Neue Trainingseinheit planen</strong> starten.
</p>
</div>
) : units.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Trainingseinheiten im gewählten Zeitraum
Keine Trainingseinheiten in diesem Zeitraum. Nutze oben <strong>Neue Trainingseinheit planen</strong> oder{' '}
<strong>Schnell erstellen</strong>, um den ersten Termin anzulegen.
</p>
</div>
) : (