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
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:
parent
1c67a50ce4
commit
0b203489f7
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,37 +325,39 @@ 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()
|
||||
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.',
|
||||
)
|
||||
} 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
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')
|
||||
|
|
@ -348,7 +365,6 @@ function ExerciseProgressionGraphPanel(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const promoteClubId = nextVis === 'club' ? resolvePromoteClubId() : null
|
||||
if (nextVis === 'club' && !promoteClubId) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user