Implement Graph Visibility Promotion Logic and Update UI Components
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 41s
Test Suite / playwright-tests (push) Successful in 1m21s

- 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.
This commit is contained in:
Lars 2026-06-14 07:30:26 +02:00
parent 1c67a50ce4
commit 0b203489f7
5 changed files with 101 additions and 34 deletions

View File

@ -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: privateclub, privateofficial, clubofficial.
"""
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,
}

View File

@ -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)

View File

@ -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():

View File

@ -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')
}
}
}
}

View File

@ -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')