diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index f062a20..cc28842 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, Field, model_validator from db import get_db, get_cursor, r2d from club_tenancy import ( assert_valid_governance_visibility, + can_manage_club_org, club_admin_shares_club_with_creator, has_club_role, is_platform_admin, @@ -778,6 +779,10 @@ def bulk_patch_exercises_metadata( Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle). Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin). + Zusätzlich: Vereinsorga (club_admin) darf **nur** bei reiner Sichtbarkeitsänderung auf ``club`` + für den eigenen Verein (`club_id` / aktiver Verein) fremde Übungen freigeben — analog + Trainingseinheit-Speichern. + Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin). """ profile_id = tenant.profile_id @@ -861,14 +866,6 @@ def bulk_patch_exercises_metadata( owner = rowd.get("created_by") if owner is not None: owner = int(owner) - if owner != profile_id and not is_platform_admin(role): - failed.append( - { - "id": ex_id, - "detail": "Keine Berechtigung (nur Ersteller oder Plattform-Admin)", - } - ) - continue ex_vis = (rowd.get("visibility") or "private").strip().lower() ex_cid_raw = rowd.get("club_id") @@ -882,18 +879,45 @@ def bulk_patch_exercises_metadata( if patch_visibility and body.club_id is not None: next_club = int(body.club_id) + if patch_visibility and next_vis == "club" and next_club is None: + eff = tenant.effective_club_id + next_club = int(eff) if eff is not None else None + + if patch_visibility and next_vis == "club" and next_club is None: + failed.append( + { + "id": ex_id, + "detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).", + } + ) + continue + + other_meta_patches = ( + patch_status + or patch_focus_areas + or patch_style_dirs + or patch_training_types + or patch_target_groups + ) + is_owner_or_platform = owner == profile_id or is_platform_admin(role) + if not is_owner_or_platform: + org_club_promo_only = ( + patch_visibility + and not other_meta_patches + and next_vis == "club" + and next_club is not None + and can_manage_club_org(cur, profile_id, int(next_club), role) + ) + if not org_club_promo_only: + failed.append( + { + "id": ex_id, + "detail": "Keine Berechtigung (Ersteller, Plattform-Admin oder Vereinsorga bei reiner Vereinsfreigabe).", + } + ) + continue + if patch_visibility: - if next_vis == "club": - if next_club is None: - next_club = tenant.effective_club_id - if next_club is None: - failed.append( - { - "id": ex_id, - "detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).", - } - ) - continue gov_club = next_club if next_vis == "club" else None try: assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club) diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index c26c871..c64e697 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -653,6 +653,109 @@ def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]): _insert_section_items(cur, sid, sec.get("items")) +def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]: + cur.execute( + """ + SELECT DISTINCT tusi.exercise_id + FROM training_unit_section_items tusi + INNER JOIN training_unit_sections tus ON tusi.section_id = tus.id + WHERE tus.training_unit_id = %s + AND tusi.item_type = 'exercise' + AND tusi.exercise_id IS NOT NULL + """, + (unit_id,), + ) + rows = cur.fetchall() or [] + out: List[int] = [] + for r in rows: + try: + out.append(int(r["exercise_id"])) + except (TypeError, ValueError, KeyError): + continue + return out + + +def _group_club_id_for_scheduled_unit(cur, unit_id: int) -> Optional[int]: + """Nur echte Gruppentermine (keine Rahmen-Blueprints ohne Gruppe).""" + cur.execute( + """ + SELECT tg.club_id + FROM training_units tu + INNER JOIN training_groups tg ON tu.group_id = tg.id + WHERE tu.id = %s AND tu.framework_slot_id IS NULL + """, + (unit_id,), + ) + r = cur.fetchone() + if not r or r.get("club_id") is None: + return None + return int(r["club_id"]) + + +def _caller_may_promote_exercise_to_club( + cur, + exercise_created_by: Optional[int], + profile_id: int, + role: str, + target_club_id: int, +) -> bool: + if is_platform_admin(role): + return True + if exercise_created_by is not None and int(exercise_created_by) == profile_id: + return True + if can_manage_club_org(cur, profile_id, target_club_id, role): + return True + return False + + +def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int, role: str) -> None: + """ + Private Übungen in der Einheit auf visibility=club (Verein der Trainingsgruppe) setzen, + damit andere Trainer und Mitglieder sie in der Durchführung sehen. + """ + target_club_id = _group_club_id_for_scheduled_unit(cur, unit_id) + if not target_club_id: + return + if not ( + is_platform_admin(role) + or _profile_active_in_club(cur, target_club_id, profile_id) + or can_manage_club_org(cur, profile_id, target_club_id, role) + ): + return + + for eid in _distinct_exercise_ids_in_unit(cur, unit_id): + cur.execute( + """ + SELECT id, created_by, visibility, club_id, COALESCE(status, '') AS status + FROM exercises WHERE id = %s + """, + (eid,), + ) + row = cur.fetchone() + if not row: + continue + if str(row.get("status") or "").strip().lower() == "archived": + continue + vis = (row.get("visibility") or "private").strip().lower() + if vis == "official": + continue + if vis == "club": + continue + if vis != "private": + continue + cb = row.get("created_by") + if not _caller_may_promote_exercise_to_club(cur, cb, profile_id, role, target_club_id): + continue + cur.execute( + """ + UPDATE exercises + SET visibility = 'club', club_id = %s, updated_at = NOW() + WHERE id = %s AND LOWER(COALESCE(visibility, 'private')) = 'private' + """, + (target_club_id, eid), + ) + + def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]): if not exercises_in: return @@ -1281,6 +1384,8 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ elif exercises_in is not None: _insert_sections_from_legacy_exercises(cur, unit_id, exercises_in) + _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) + conn.commit() return get_training_unit(unit_id, tenant) @@ -1461,6 +1566,9 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) _insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or []) + if content_handled or "sections" in data or "exercises" in data: + _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) + conn.commit() return get_training_unit(unit_id, tenant) @@ -1572,6 +1680,8 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = slot_id, ) + _promote_private_exercises_used_in_unit(cur, new_id, profile_id, role) + conn.commit() return get_training_unit(new_id, tenant) @@ -1644,6 +1754,7 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t if tpl_id_safe: _instantiate_from_template(cur, unit_id, tpl_id_safe) + _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) conn.commit() diff --git a/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx b/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx new file mode 100644 index 0000000..a14990c --- /dev/null +++ b/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx @@ -0,0 +1,280 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../utils/api' + +const VIS_LABELS = { + private: 'Privat', + club: 'Verein', + official: 'Offiziell', +} + +function collectExerciseRows(sections) { + const map = new Map() + for (const sec of sections || []) { + for (const it of sec.items || []) { + if (it.item_type === 'note') continue + const id = Number(it.exercise_id) + if (!Number.isFinite(id) || id < 1) continue + if (!map.has(id)) { + map.set(id, it) + } + } + } + return [...map.entries()].map(([id, it]) => ({ + id, + title: it.exercise_title || `Übung #${id}`, + visibility: it.exercise_visibility, + clubId: it.exercise_club_id != null ? Number(it.exercise_club_id) : null, + createdBy: it.exercise_created_by != null ? Number(it.exercise_created_by) : null, + status: it.exercise_status, + })) +} + +function needsClubForTarget(row, targetClubId) { + if (targetClubId == null || !Number.isFinite(Number(targetClubId))) return false + const vis = String(row.visibility || 'private').toLowerCase() + if (vis === 'official') return false + const tc = Number(targetClubId) + if (vis === 'private') return true + if (vis === 'club') { + if (row.clubId == null) return true + return row.clubId !== tc + } + return false +} + +function userMayPromote(user, targetClubId, createdBy) { + if (!user || targetClubId == null) return false + const role = String(user.role || '').toLowerCase() + if (role === 'admin' || role === 'superadmin') return true + if (createdBy != null && Number(createdBy) === Number(user.id)) return true + const row = (user.clubs || []).find((c) => Number(c.id) === Number(targetClubId)) + if (!row || !Array.isArray(row.roles)) return false + return row.roles.includes('club_admin') +} + +/** + * Listen-Panel im Trainingsplan: Übungen, die für die gewählte Gruppe noch nicht vereinsweit sichtbar sind, + * und Freigabe auf „Verein“ (API: PUT / bulk-metadata). + */ +export default function TrainingPlanExerciseVisibilityPanel({ + sections, + targetClubId, + user, + onMetaRefresh, +}) { + const [busyId, setBusyId] = useState(null) + const [bulkBusy, setBulkBusy] = useState(false) + const [message, setMessage] = useState(null) + + const rows = useMemo(() => collectExerciseRows(sections), [sections]) + + const { pending, okCount } = useMemo(() => { + if (targetClubId == null || !Number.isFinite(Number(targetClubId))) { + return { pending: [], okCount: 0 } + } + const pending = [] + let okCount = 0 + for (const r of rows) { + if (needsClubForTarget(r, targetClubId)) pending.push(r) + else okCount += 1 + } + return { pending, okCount } + }, [rows, targetClubId]) + + const promotableIds = useMemo( + () => pending.filter((r) => userMayPromote(user, targetClubId, r.createdBy)).map((r) => r.id), + [pending, targetClubId, user] + ) + + const applyClubVisibility = useCallback( + async (exerciseIds) => { + if (!exerciseIds.length || targetClubId == null) return + setMessage(null) + const res = await api.bulkPatchExercisesMetadata({ + exercise_ids: exerciseIds, + visibility: 'club', + club_id: targetClubId, + }) + const failed = res?.failed || [] + const updatedN = Number(res?.updated_count || 0) + if (updatedN > 0 && onMetaRefresh) { + await onMetaRefresh() + } + if (failed.length) { + const first = failed[0]?.detail || 'Unbekannter Fehler' + setMessage( + failed.length === 1 + ? String(first) + : `${failed.length} Übungen nicht geändert: ${first}` + ) + } + }, + [targetClubId, onMetaRefresh] + ) + + const onPromoteOne = useCallback( + async (id) => { + setBusyId(id) + setMessage(null) + try { + await applyClubVisibility([id]) + } catch (e) { + setMessage(e?.message || String(e)) + } finally { + setBusyId(null) + } + }, + [applyClubVisibility] + ) + + const onPromoteAll = useCallback(async () => { + if (!promotableIds.length) return + setBulkBusy(true) + setMessage(null) + try { + await applyClubVisibility(promotableIds) + } catch (e) { + setMessage(e?.message || String(e)) + } finally { + setBulkBusy(false) + } + }, [applyClubVisibility, promotableIds]) + + if (!rows.length) return null + + return ( +
+ Sichtbarkeit für den Verein +

+ Übungen mit Sichtbarkeit „Privat“ oder einem anderen Verein sieht das Team bei der Durchführung + nicht. Hier können Sie sie auf Verein setzen (gleiche Logik wie beim Speichern der + Einheit). +

+ {targetClubId == null || !Number.isFinite(Number(targetClubId)) ? ( +

+ Wählen Sie eine Trainingsgruppe, um passende Freigaben anzuzeigen. +

+ ) : null} + {targetClubId != null && Number.isFinite(Number(targetClubId)) && !pending.length && rows.length ? ( +

+ Alle {rows.length} {rows.length === 1 ? 'Übung ist' : 'Übungen sind'} für diesen Verein in der + Durchführung sichtbar (oder offiziell). +

+ ) : null} + {targetClubId != null && Number.isFinite(Number(targetClubId)) && pending.length ? ( + <> +
+ + {okCount > 0 ? ( + + {okCount} weitere {okCount === 1 ? 'Übung' : 'Übungen'} bereits passend + + ) : null} +
+ + {pending.some((r) => !userMayPromote(user, targetClubId, r.createdBy)) ? ( +

+ Einige Einträge können Sie nicht selbst freigeben: Denken Sie an die Vereinsorga oder speichern Sie + die Einheit — bei ausreichender Berechtigung werden private Übungen dann automatisch mitgeführt. +

+ ) : null} + + ) : null} + {message ? ( +

+ {message} +

+ ) : null} +
+ ) +} diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index 7d15091..e41289b 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext' import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' +import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel' import PageSectionNav from '../components/PageSectionNav' import { defaultSection, @@ -175,6 +176,22 @@ function TrainingPlanningPage() { sections: [defaultSection()], ...sessionAssignDefaults() }) + const planningFormRef = useRef(formData) + planningFormRef.current = formData + + const planningModalClubId = useMemo(() => { + const gid = Number(formData.group_id) + if (!Number.isFinite(gid) || gid < 1) return null + const g = groups.find((x) => Number(x.id) === gid) + if (!g || g.club_id == null || g.club_id === '') return null + const c = Number(g.club_id) + return Number.isFinite(c) ? c : null + }, [groups, formData.group_id]) + + const refreshPlanningSectionMeta = useCallback(async () => { + const next = await enrichSectionsWithVariants(planningFormRef.current.sections) + setFormData((prev) => ({ ...prev, sections: next })) + }, []) const loadPlanTemplates = useCallback(async () => { try { @@ -2204,6 +2221,13 @@ function TrainingPlanningPage() { ) : null} + +
{editingUnit ? (
diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index 01c9b4e..de9670e 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -23,20 +23,48 @@ export async function hydrateExercisePlanningRow(exercise) { let title = exercise?.title || '' const id = exercise?.id if (!id) return null + let meta = {} if (!variants.length) { try { const full = await api.getExercise(id) variants = Array.isArray(full?.variants) ? full.variants : [] title = full?.title || title + meta = { + exercise_visibility: full?.visibility || 'private', + exercise_club_id: full?.club_id ?? null, + exercise_created_by: full?.created_by ?? null, + exercise_status: full?.status || 'draft', + } } catch { variants = [] } + } else { + meta = { + exercise_visibility: exercise?.visibility ?? null, + exercise_club_id: exercise?.club_id ?? null, + exercise_created_by: exercise?.created_by ?? null, + exercise_status: exercise?.status ?? null, + } + if (meta.exercise_visibility == null || meta.exercise_created_by == null) { + try { + const full = await api.getExercise(id) + if (meta.exercise_visibility == null) meta.exercise_visibility = full?.visibility || 'private' + if (meta.exercise_club_id == null) meta.exercise_club_id = full?.club_id ?? null + if (meta.exercise_created_by == null) meta.exercise_created_by = full?.created_by ?? null + if (meta.exercise_status == null) meta.exercise_status = full?.status || 'draft' + } catch { + /* keep partial meta */ + } + } + meta.exercise_visibility = meta.exercise_visibility || 'private' + meta.exercise_status = meta.exercise_status || 'draft' } const row = exerciseRow() row.exercise_id = id row.exercise_variant_id = '' row.exercise_title = title row.variants = variants + Object.assign(row, meta) return row } @@ -119,9 +147,20 @@ export async function enrichSectionsWithVariants(sections) { cache.set(id, { title: ex.title || '', variants: Array.isArray(ex.variants) ? ex.variants : [], + visibility: ex.visibility || 'private', + club_id: ex.club_id ?? null, + created_by: ex.created_by ?? null, + status: ex.status || 'draft', }) } catch { - cache.set(id, { title: '', variants: [] }) + cache.set(id, { + title: '', + variants: [], + visibility: 'private', + club_id: null, + created_by: null, + status: 'draft', + }) } }) ) @@ -137,6 +176,10 @@ export async function enrichSectionsWithVariants(sections) { exercise_title: it.exercise_title || c.title, variants: Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants, + exercise_visibility: c.visibility, + exercise_club_id: c.club_id, + exercise_created_by: c.created_by, + exercise_status: c.status, } }), }))