feat: add club visibility queue for training exercises
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s

- Implemented a new API endpoint to retrieve exercises in training units that are not yet visible to the target club, enhancing visibility management for trainers.
- Introduced a new widget in the Dashboard to display these exercises, improving user access to relevant training information.
- Added helper functions to determine exercise visibility based on club membership and status, streamlining the exercise promotion process.
This commit is contained in:
Lars 2026-05-07 10:19:55 +02:00
parent f9d518fb78
commit fe703ca414
4 changed files with 541 additions and 0 deletions

View File

@ -4,6 +4,7 @@ und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
""" """
from datetime import date, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -692,6 +693,26 @@ def _group_club_id_for_scheduled_unit(cur, unit_id: int) -> Optional[int]:
return int(r["club_id"]) return int(r["club_id"])
def _exercise_needs_club_visibility_for_target(ex: Dict[str, Any], target_club_id: int) -> bool:
"""Übung für Mitglieder des Ziel-Vereins in der Durchführung sichtbar machen (Dashboard/Queue)."""
if str(ex.get("status") or "").strip().lower() == "archived":
return False
vis = (ex.get("visibility") or "private").strip().lower()
if vis == "official":
return False
if vis == "private":
return True
if vis == "club":
raw = ex.get("club_id")
if raw is None:
return True
try:
return int(raw) != int(target_club_id)
except (TypeError, ValueError):
return True
return False
def _caller_may_promote_exercise_to_club( def _caller_may_promote_exercise_to_club(
cur, cur,
exercise_created_by: Optional[int], exercise_created_by: Optional[int],
@ -1209,6 +1230,163 @@ def list_training_units(
return [r2d(r) for r in rows] return [r2d(r) for r in rows]
@router.get("/training-units/exercises-club-visibility-queue")
def exercises_club_visibility_queue(
start_date: Optional[str] = Query(default=None),
end_date: Optional[str] = Query(default=None),
assigned_to_me: bool = Query(default=True),
limit_units: int = Query(default=80, ge=1, le=150),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Übungen in deinen Trainingseinheiten (Zeitfenster), die für den jeweiligen Verein der Gruppe
noch nicht vereinsweit sichtbar sind für Dashboard & Freigabe-Workflow.
"""
profile_id = tenant.profile_id
role = tenant.global_role
if start_date is None:
start_date = (date.today() - timedelta(days=45)).isoformat()
if end_date is None:
end_date = (date.today() + timedelta(days=365)).isoformat()
units = list_training_units(
group_id=None,
club_id=None,
start_date=start_date,
end_date=end_date,
status=None,
assigned_to_me=assigned_to_me,
debrief_pending=False,
sort="asc",
limit=limit_units,
tenant=tenant,
)
unit_ids = [int(u["id"]) for u in units if u.get("id") is not None]
if not unit_ids:
return {"items": []}
placeholders = ",".join(["%s"] * len(unit_ids))
items: List[Dict[str, Any]] = []
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"""
SELECT DISTINCT tu.id AS unit_id,
tu.planned_date,
tg.name AS group_name,
tg.club_id AS target_club_id,
c.name AS target_club_name,
tusi.exercise_id AS exercise_id
FROM training_units tu
INNER JOIN training_groups tg ON tu.group_id = tg.id
LEFT JOIN clubs c ON c.id = tg.club_id
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
WHERE tu.id IN ({placeholders})
AND tu.framework_slot_id IS NULL
AND tusi.item_type = 'exercise'
AND tusi.exercise_id IS NOT NULL
""",
tuple(unit_ids),
)
pairs = [r2d(r) for r in cur.fetchall()]
if not pairs:
return {"items": []}
ex_ids = sorted(
{int(p["exercise_id"]) for p in pairs if p.get("exercise_id") is not None}
)
if not ex_ids:
return {"items": []}
exercises_map: Dict[int, Dict[str, Any]] = {}
ph = ",".join(["%s"] * len(ex_ids))
cur.execute(
f"""
SELECT id, title, visibility, club_id, created_by, status
FROM exercises
WHERE id IN ({ph})
""",
tuple(ex_ids),
)
for r in cur.fetchall():
d = r2d(r)
exercises_map[int(d["id"])] = d
agg: Dict[tuple, Dict[str, Any]] = {}
for p in pairs:
try:
ex_id = int(p["exercise_id"])
except (TypeError, ValueError):
continue
tc_raw = p.get("target_club_id")
if tc_raw is None:
continue
tc = int(tc_raw)
key = (ex_id, tc)
if key not in agg:
agg[key] = {
"exercise_id": ex_id,
"target_club_id": tc,
"target_club_name": (p.get("target_club_name") or "").strip(),
"units": [],
}
uid = p.get("unit_id")
if uid is None:
continue
agg[key]["units"].append(
{
"id": int(uid),
"planned_date": str(p["planned_date"]) if p.get("planned_date") is not None else "",
"group_name": (p.get("group_name") or "").strip(),
}
)
for _key, blob in agg.items():
ex_id = blob["exercise_id"]
tc = blob["target_club_id"]
ex = exercises_map.get(ex_id)
if not ex:
continue
if not _exercise_needs_club_visibility_for_target(ex, tc):
continue
uniq_units = {u["id"]: u for u in blob["units"]}.values()
ulist = sorted(
uniq_units,
key=lambda x: (x.get("planned_date") or "", x.get("id")),
)
cb = ex.get("created_by")
cb_int = int(cb) if cb is not None else None
can_promote = _caller_may_promote_exercise_to_club(cur, cb_int, profile_id, role, tc)
vis = (ex.get("visibility") or "private").strip().lower()
st = (ex.get("status") or "draft").strip().lower()
ecid = ex.get("club_id")
items.append(
{
"exercise_id": ex_id,
"title": (ex.get("title") or f"Übung #{ex_id}").strip() or f"Übung #{ex_id}",
"visibility": vis,
"status": st,
"club_id": int(ecid) if ecid is not None else None,
"created_by": cb_int,
"target_club_id": tc,
"target_club_name": blob.get("target_club_name") or "",
"can_promote": can_promote,
"units": ulist,
}
)
items.sort(
key=lambda x: (
(x["units"][0].get("planned_date") if x["units"] else ""),
x["title"],
)
)
return {"items": items}
@router.get("/training-units/{unit_id}") @router.get("/training-units/{unit_id}")
def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id profile_id = tenant.profile_id

View File

@ -0,0 +1,347 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { CalendarDays, ClipboardList } from 'lucide-react'
import api from '../utils/api'
const VIS_LABELS = { private: 'Privat', club: 'Verein', official: 'Offiziell' }
const STATUS_LABELS = {
draft: 'Entwurf',
in_review: 'Prüfung',
approved: 'Freigegeben',
archived: 'Archiv',
}
function rowKey(item) {
return `${item.exercise_id}-${item.target_club_id}`
}
function unitPlanTitle(u) {
const d = (u.planned_date || '').toString().slice(0, 10)
const g = (u.group_name || '').trim()
return [d, g].filter(Boolean).join(' · ') || `Einheit #${u.id}`
}
/**
* Dashboard: Übungen aus eigenen Trainingseinheiten, die für den Verein der Gruppe noch nicht freigegeben sind.
*/
export default function DashboardTrainingVisibilityWidget({ user }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [err, setErr] = useState(null)
const [selected, setSelected] = useState(() => new Set())
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const load = useCallback(async () => {
if (!user?.id) return
setErr(null)
setLoading(true)
try {
const res = await api.getTrainingExerciseClubVisibilityQueue({ limit_units: 100 })
const list = Array.isArray(res?.items) ? res.items : []
setItems(list)
setSelected(new Set())
setMsg(null)
} catch (e) {
setErr(e?.message || String(e))
setItems([])
} finally {
setLoading(false)
}
}, [user?.id])
useEffect(() => {
load()
}, [load])
const promotableSelected = useMemo(() => {
const out = []
for (const k of selected) {
const it = items.find((x) => rowKey(x) === k)
if (it?.can_promote) out.push(it)
}
return out
}, [items, selected])
const toggle = (key) => {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(key)) n.delete(key)
else n.add(key)
return n
})
}
const toggleAllPromotable = () => {
const keys = items.filter((i) => i.can_promote).map(rowKey)
setSelected((prev) => {
if (keys.length && keys.every((k) => prev.has(k))) {
return new Set()
}
return new Set(keys)
})
}
const promoteSelected = async () => {
if (!promotableSelected.length) return
setBusy(true)
setMsg(null)
try {
const byClub = new Map()
for (const it of promotableSelected) {
const cid = it.target_club_id
if (!byClub.has(cid)) byClub.set(cid, [])
byClub.get(cid).push(it.exercise_id)
}
let anyFail = false
for (const [clubId, ids] of byClub) {
const uniq = [...new Set(ids)]
const res = await api.bulkPatchExercisesMetadata({
exercise_ids: uniq,
visibility: 'club',
club_id: clubId,
})
if ((res?.failed || []).length) {
anyFail = true
const f = res.failed[0]
setMsg(f?.detail || 'Freigabe teilweise fehlgeschlagen')
}
}
if (!anyFail) setMsg(null)
await load()
} catch (e) {
setMsg(e?.message || String(e))
} finally {
setBusy(false)
}
}
if (!user?.id) return null
if (loading) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }}>
<p className="muted" style={{ margin: 0 }}>
Vereinsfreigaben werden geladen
</p>
</div>
)
}
if (err) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }} role="alert">
<p style={{ margin: 0, color: 'var(--danger, #c0392b)' }}>{err}</p>
</div>
)
}
if (!items.length) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }}>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Vereinssichtbarkeit in deinen Trainings
</h3>
<p className="dashboard-preview-card__empty" style={{ margin: 0 }}>
Keine Übungen in den abgefragten Einheiten, die noch auf Verein gestellt werden müssten.
</p>
</div>
)
}
const allPromo = items.filter((i) => i.can_promote)
const allSelected = allPromo.length > 0 && allPromo.every((i) => selected.has(rowKey(i)))
return (
<div className="card dashboard-preview-card dashboard-vis-queue" style={{ marginTop: '1rem' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Vereinssichtbarkeit in deinen Trainings
</h3>
<p
className="muted"
style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', lineHeight: 1.45, maxWidth: '52rem' }}
>
Übungen in deinen Einheiten, die für den jeweiligen Verein noch nicht sichtbar sind auf{' '}
<strong>Verein</strong> setzen oder zur Bearbeitung / Planung springen.
</p>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
{allPromo.length ? (
<button type="button" className="btn btn-secondary" onClick={toggleAllPromotable}>
{allSelected ? 'Auswahl leeren' : 'Alle wählbar'}
</button>
) : null}
<button
type="button"
className="btn btn-primary"
disabled={!promotableSelected.length || busy}
onClick={promoteSelected}
>
{busy ? 'Speichern…' : `Freigeben (${promotableSelected.length})`}
</button>
</div>
</div>
<div style={{ marginTop: '0.75rem', overflowX: 'auto' }}>
<table
className="dashboard-vis-queue__table"
style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}
>
<thead>
<tr
style={{
textAlign: 'left',
borderBottom: '1px solid var(--border2)',
color: 'var(--text3)',
fontSize: '0.78rem',
}}
>
<th style={{ width: 32, padding: '6px 4px' }} aria-label="Auswahl" />
<th style={{ padding: '6px 6px' }}>Übung</th>
<th style={{ padding: '6px 6px', whiteSpace: 'nowrap' }}>Sichtbarkeit</th>
<th style={{ padding: '6px 6px', whiteSpace: 'nowrap' }}>Status</th>
<th style={{ padding: '6px 6px' }}>Verein</th>
<th style={{ padding: '6px 4px', width: 76 }} title="Planung und Durchführung">
Kontext
</th>
</tr>
</thead>
<tbody>
{items.map((it) => {
const k = rowKey(it)
const on = selected.has(k)
const visL = VIS_LABELS[it.visibility] || it.visibility
const stL = STATUS_LABELS[it.status] || it.status
const first = it.units && it.units[0]
const restN = (it.units?.length || 0) - 1
const tool = (it.units || []).map(unitPlanTitle).join('\n')
const cn = (it.target_club_name || '').trim()
return (
<tr
key={k}
style={{ borderBottom: '1px solid var(--border2)' }}
title={restN > 0 ? tool : undefined}
>
<td style={{ padding: '6px 4px', verticalAlign: 'top' }}>
<input
type="checkbox"
checked={on}
disabled={!it.can_promote}
onChange={() => toggle(k)}
title={it.can_promote ? 'Zur Freigabe wählen' : 'Keine Berechtigung für diese Freigabe'}
aria-label={`${it.title} auswählen`}
/>
</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', minWidth: 140 }}>
<Link
to={`/exercises/${it.exercise_id}/edit`}
className="dashboard-preview-card__link"
style={{ fontWeight: 600 }}
>
{it.title}
</Link>
</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>{visL}</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>{stL}</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', maxWidth: 200 }}>
<span title={cn}>{cn.length > 28 ? `${cn.slice(0, 28)}` : cn || '—'}</span>
</td>
<td style={{ padding: '6px 4px', verticalAlign: 'top' }}>
{first ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
<Link
to={`/planning?unit=${first.id}`}
className="btn-ghost"
style={{
padding: '2px 4px',
display: 'inline-flex',
borderRadius: 6,
textDecoration: 'none',
color: 'var(--text1)',
}}
title={
restN > 0
? `Planung (${unitPlanTitle(first)}; +${restN} weitere — siehe Tooltip ganze Zeile)`
: `Planung öffnen: ${unitPlanTitle(first)}`
}
aria-label="Planung öffnen"
>
<CalendarDays size={16} strokeWidth={2} aria-hidden />
</Link>
<Link
to={`/planning/run/${first.id}`}
className="btn-ghost"
style={{
padding: '2px 4px',
display: 'inline-flex',
borderRadius: 6,
textDecoration: 'none',
color: 'var(--text1)',
}}
title="Durchführung / Ablauf"
aria-label="Durchführung öffnen"
>
<ClipboardList size={16} strokeWidth={2} aria-hidden />
</Link>
{restN > 0 ? (
<span className="muted" style={{ fontSize: '0.72rem' }} title={tool}>
+{restN}
</span>
) : null}
</div>
) : (
'—'
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{items.some((i) => !i.can_promote) ? (
<p className="muted" style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', lineHeight: 1.45 }}>
Ausgegraute Kästchen: keine direkte Freigabe-Berechtigung Vereinsorga kontaktieren oder die Einheit in
der Planung speichern (dann ggf. automatische Vereinsfreigabe).
</p>
) : null}
<p className="muted" style={{ margin: '0.5rem 0 0', fontSize: '0.8rem', lineHeight: 1.45 }}>
Mehrere Termine: Kalender-Icon nutzt den frühesten; +N listet alle Daten und Gruppen im Tooltip.
</p>
{msg ? (
<p
style={{ margin: '0.65rem 0 0', fontSize: '0.84rem', color: 'var(--danger, #c0392b)' }}
role="alert"
>
{msg}
</p>
) : null}
<p style={{ margin: '0.65rem 0 0', fontSize: '0.82rem' }}>
<Link to="/planning">Zur Trainingsplanung</Link>
<span className="muted"> · Zeitraum ca. 45 Tage zurück bis 1 Jahr voraus; bis zu 100 Einheiten.</span>
</p>
</div>
)
}

View File

@ -4,6 +4,7 @@ import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import EmailVerificationBanner from '../components/EmailVerificationBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner'
import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget'
function unitWhenLabel(u) { function unitWhenLabel(u) {
const d = u.planned_date ? String(u.planned_date).slice(0, 10) : '' const d = u.planned_date ? String(u.planned_date).slice(0, 10) : ''
@ -367,6 +368,7 @@ function Dashboard() {
)} )}
</div> </div>
</div> </div>
<DashboardTrainingVisibilityWidget user={user} />
</section> </section>
</> </>
) : null} ) : null}

View File

@ -1004,6 +1004,19 @@ export async function listTrainingUnits(filters = {}) {
return request(`/api/training-units${qs ? `?${qs}` : ''}`) return request(`/api/training-units${qs ? `?${qs}` : ''}`)
} }
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
const q = new URLSearchParams()
if (filters.start_date) q.set('start_date', String(filters.start_date))
if (filters.end_date) q.set('end_date', String(filters.end_date))
if (filters.assigned_to_me === false) q.set('assigned_to_me', 'false')
if (filters.limit_units != null && filters.limit_units !== '') {
q.set('limit_units', String(filters.limit_units))
}
const qs = q.toString()
return request(`/api/training-units/exercises-club-visibility-queue${qs ? `?${qs}` : ''}`)
}
export async function getTrainingUnit(id) { export async function getTrainingUnit(id) {
return request(`/api/training-units/${id}`) return request(`/api/training-units/${id}`)
} }
@ -1192,6 +1205,7 @@ export const api = {
// Training Planning // Training Planning
listTrainingUnits, listTrainingUnits,
getTrainingExerciseClubVisibilityQueue,
getTrainingUnit, getTrainingUnit,
createTrainingUnit, createTrainingUnit,
updateTrainingUnit, updateTrainingUnit,