From 0b203489f7be11fc606e7f40d8e5b7627ea6fb72 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Jun 2026 07:30:26 +0200 Subject: [PATCH] Implement Graph Visibility Promotion Logic and Update UI Components - Added a new function `_graph_promotion_transition` to determine the necessary exercise visibility changes during graph promotions. - Updated the `list_visibility_promotion_candidates` endpoint to utilize the new promotion logic, ensuring accurate exercise visibility handling. - Enhanced the frontend components to prompt users for exercise visibility adjustments based on graph visibility changes, improving user experience. - Introduced tests for the new promotion logic to ensure correctness and reliability in visibility transitions. --- .../routers/exercise_progression_graphs.py | 35 ++++++++--- backend/routers/planning_exercise_suggest.py | 1 - ...t_exercise_progression_graph_visibility.py | 23 ++++++- .../ExerciseProgressionGraphPanel.jsx | 62 ++++++++++++------- .../src/components/ProgressionGraphEditor.jsx | 14 ++++- 5 files changed, 101 insertions(+), 34 deletions(-) diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 7b3c0bf..b2d3419 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -394,6 +394,22 @@ def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]: return ids +def _graph_promotion_transition(graph_visibility: str, target_visibility: str) -> Optional[tuple[str, ...]]: + """ + Erlaubte Graph-Promotions und welche Übungs-Sichtbarkeiten mit angehoben werden müssen. + + Returns None wenn kein Übungs-Promotion-Hinweis nötig. + """ + gvis = (graph_visibility or "private").strip().lower() + tvis = (target_visibility or "").strip().lower() + transitions: Dict[tuple[str, str], tuple[str, ...]] = { + ("private", "club"): ("private",), + ("private", "official"): ("private", "club"), + ("club", "official"): ("private", "club"), + } + return transitions.get((gvis, tvis)) + + @router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates") def list_visibility_promotion_candidates( graph_id: int, @@ -401,7 +417,9 @@ def list_visibility_promotion_candidates( tenant: TenantContext = Depends(get_tenant_context), ): """ - Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten. + Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten. + + Unterstützt: private→club, private→official, club→official. """ profile_id = tenant.profile_id role = tenant.global_role @@ -409,11 +427,13 @@ def list_visibility_promotion_candidates( cur = get_cursor(conn) row = _require_graph_read(cur, graph_id, profile_id, role) graph_vis = (row.get("visibility") or "private").strip().lower() - if graph_vis != "private" or target_visibility != "club": + target_vis = (target_visibility or "club").strip().lower() + need_vis = _graph_promotion_transition(graph_vis, target_vis) + if not need_vis: return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": [], } ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id) @@ -421,19 +441,20 @@ def list_visibility_promotion_candidates( return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": [], } + vis_placeholders = ",".join(["%s"] * len(need_vis)) ph = ",".join(["%s"] * len(ref_ids)) cur.execute( f""" SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph}) - AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private' + AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders}) ORDER BY title """, - list(ref_ids), + list(ref_ids) + list(need_vis), ) exercises = [] for ex in cur.fetchall(): @@ -457,7 +478,7 @@ def list_visibility_promotion_candidates( return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": exercises, } diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index beb0934..8619cd6 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -70,7 +70,6 @@ def post_progression_path_suggest( uses_ai = ( body.include_llm_intent or body.include_llm_path_qa - or body.include_ai_gap_fill or body.include_llm_roadmap or body.include_llm_start_target or (body.start_target_only and body.include_llm_start_target) diff --git a/backend/tests/test_exercise_progression_graph_visibility.py b/backend/tests/test_exercise_progression_graph_visibility.py index 98cac37..5a01668 100644 --- a/backend/tests/test_exercise_progression_graph_visibility.py +++ b/backend/tests/test_exercise_progression_graph_visibility.py @@ -1,5 +1,26 @@ """Sichtbarkeit: Progressionsgraph ↔ Übungen (Promotion, Kanten, Match).""" -from routers.exercise_progression_graphs import _exercise_allowed_in_progression_graph +from routers.exercise_progression_graphs import ( + _exercise_allowed_in_progression_graph, + _graph_promotion_transition, +) + + +def test_graph_promotion_transition_private_to_club(): + assert _graph_promotion_transition("private", "club") == ("private",) + + +def test_graph_promotion_transition_private_to_official(): + assert _graph_promotion_transition("private", "official") == ("private", "club") + + +def test_graph_promotion_transition_club_to_official(): + assert _graph_promotion_transition("club", "official") == ("private", "club") + + +def test_graph_promotion_transition_noop(): + assert _graph_promotion_transition("club", "club") is None + assert _graph_promotion_transition("official", "club") is None + assert _graph_promotion_transition("private", "private") is None def test_club_graph_rejects_private_exercise(): diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 68a3038..8beac1e 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -24,6 +24,21 @@ const VIS_OPTIONS = [ { value: 'official', label: 'Offiziell' }, ] +const GRAPH_VISIBILITY_PROMOTION_LABEL = { + club: 'Vereins-Sichtbarkeit', + official: 'offizielle Sichtbarkeit', +} + +/** Graph-Promotion mit optionalem Übungs-Anheben (private→club/official, club→official). */ +function shouldPromptGraphExercisePromotion(prevVis, nextVis) { + const p = (prevVis || 'private').trim().toLowerCase() + const n = (nextVis || 'private').trim().toLowerCase() + return ( + (p === 'private' && (n === 'club' || n === 'official')) || + (p === 'club' && n === 'official') + ) +} + function edgeTypeLabel(type) { if (type === 'next_exercise') return 'Nachfolger' if (type === 'sibling') return 'Schwester' @@ -310,42 +325,43 @@ function ExerciseProgressionGraphPanel( setBusy(true) try { - if (prevVis === 'private' && nextVis === 'club') { + if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) { const preview = await api.getProgressionGraphVisibilityPromotionCandidates( selectedGraphId, - { targetVisibility: 'club' }, + { targetVisibility: nextVis }, ) - const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] - if (privateExercises.length > 0) { - const titles = privateExercises + const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] + if (promotionExercises.length > 0) { + const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis + const titles = promotionExercises .slice(0, 8) .map((ex) => `• ${ex.title || `Übung #${ex.id}`}`) .join('\n') const more = - privateExercises.length > 8 - ? `\n… und ${privateExercises.length - 8} weitere` + promotionExercises.length > 8 + ? `\n… und ${promotionExercises.length - 8} weitere` : '' const promote = window.confirm( - `Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`, + `Der Graph wird auf „${visLabel}“ gestellt. Im Graph sind noch ${promotionExercises.length} Übung(en) mit niedrigerer Sichtbarkeit:\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf ${visLabel} anheben?`, ) if (promote) { - const clubId = resolvePromoteClubId() - if (!clubId) { - throw new Error( - 'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.', - ) - } else { - const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null) - const res = await api.bulkPatchExercisesMetadata({ - exercise_ids: ids, - visibility: 'club', - club_id: clubId, - }) - if ((res?.failed || []).length) { - const f = res.failed[0] - throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') + let clubId = null + if (nextVis === 'club') { + clubId = resolvePromoteClubId() + if (!clubId) { + throw new Error( + 'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.', + ) } } + const ids = promotionExercises.map((ex) => ex.id).filter((id) => id != null) + const bulkPayload = { exercise_ids: ids, visibility: nextVis } + if (nextVis === 'club' && clubId != null) bulkPayload.club_id = clubId + const res = await api.bulkPatchExercisesMetadata(bulkPayload) + if ((res?.failed || []).length) { + const f = res.failed[0] + throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') + } } } } diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 5b87768..4bb2b81 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import { useAuth } from '../context/AuthContext' +import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ProgressionSlotCard from './ProgressionSlotCard' @@ -85,6 +87,7 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) { } export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) { + const { user } = useAuth() const [graphMeta, setGraphMeta] = useState(null) const [draft, setDraft] = useState(null) const [busy, setBusy] = useState(false) @@ -880,9 +883,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setSlotQuickSaving(true) setSlotQuickError('') try { + const graphVis = (graphMeta?.visibility || 'private').trim().toLowerCase() + const graphClubId = + graphMeta?.club_id != null + ? graphMeta.club_id + : graphVis === 'club' + ? getDefaultClubIdForGovernanceForms(user) + : null const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, { - visibility: graphMeta?.visibility || 'private', - clubId: graphMeta?.club_id ?? null, + visibility: graphVis, + clubId: graphClubId, }) const created = await api.createExercise(payload) if (!created?.id) throw new Error('Anlegen fehlgeschlagen')