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
|
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")
|
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
|
||||||
def list_visibility_promotion_candidates(
|
def list_visibility_promotion_candidates(
|
||||||
graph_id: int,
|
graph_id: int,
|
||||||
|
|
@ -401,7 +417,9 @@ def list_visibility_promotion_candidates(
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
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
|
profile_id = tenant.profile_id
|
||||||
role = tenant.global_role
|
role = tenant.global_role
|
||||||
|
|
@ -409,11 +427,13 @@ def list_visibility_promotion_candidates(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||||
graph_vis = (row.get("visibility") or "private").strip().lower()
|
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 {
|
return {
|
||||||
"graph_id": graph_id,
|
"graph_id": graph_id,
|
||||||
"graph_visibility": graph_vis,
|
"graph_visibility": graph_vis,
|
||||||
"target_visibility": target_visibility,
|
"target_visibility": target_vis,
|
||||||
"exercises": [],
|
"exercises": [],
|
||||||
}
|
}
|
||||||
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
|
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
|
||||||
|
|
@ -421,19 +441,20 @@ def list_visibility_promotion_candidates(
|
||||||
return {
|
return {
|
||||||
"graph_id": graph_id,
|
"graph_id": graph_id,
|
||||||
"graph_visibility": graph_vis,
|
"graph_visibility": graph_vis,
|
||||||
"target_visibility": target_visibility,
|
"target_visibility": target_vis,
|
||||||
"exercises": [],
|
"exercises": [],
|
||||||
}
|
}
|
||||||
|
vis_placeholders = ",".join(["%s"] * len(need_vis))
|
||||||
ph = ",".join(["%s"] * len(ref_ids))
|
ph = ",".join(["%s"] * len(ref_ids))
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, title, visibility, club_id, created_by
|
SELECT id, title, visibility, club_id, created_by
|
||||||
FROM exercises
|
FROM exercises
|
||||||
WHERE id IN ({ph})
|
WHERE id IN ({ph})
|
||||||
AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private'
|
AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders})
|
||||||
ORDER BY title
|
ORDER BY title
|
||||||
""",
|
""",
|
||||||
list(ref_ids),
|
list(ref_ids) + list(need_vis),
|
||||||
)
|
)
|
||||||
exercises = []
|
exercises = []
|
||||||
for ex in cur.fetchall():
|
for ex in cur.fetchall():
|
||||||
|
|
@ -457,7 +478,7 @@ def list_visibility_promotion_candidates(
|
||||||
return {
|
return {
|
||||||
"graph_id": graph_id,
|
"graph_id": graph_id,
|
||||||
"graph_visibility": graph_vis,
|
"graph_visibility": graph_vis,
|
||||||
"target_visibility": target_visibility,
|
"target_visibility": target_vis,
|
||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ def post_progression_path_suggest(
|
||||||
uses_ai = (
|
uses_ai = (
|
||||||
body.include_llm_intent
|
body.include_llm_intent
|
||||||
or body.include_llm_path_qa
|
or body.include_llm_path_qa
|
||||||
or body.include_ai_gap_fill
|
|
||||||
or body.include_llm_roadmap
|
or body.include_llm_roadmap
|
||||||
or body.include_llm_start_target
|
or body.include_llm_start_target
|
||||||
or (body.start_target_only and 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)."""
|
"""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():
|
def test_club_graph_rejects_private_exercise():
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,21 @@ const VIS_OPTIONS = [
|
||||||
{ value: 'official', label: 'Offiziell' },
|
{ 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) {
|
function edgeTypeLabel(type) {
|
||||||
if (type === 'next_exercise') return 'Nachfolger'
|
if (type === 'next_exercise') return 'Nachfolger'
|
||||||
if (type === 'sibling') return 'Schwester'
|
if (type === 'sibling') return 'Schwester'
|
||||||
|
|
@ -310,42 +325,43 @@ function ExerciseProgressionGraphPanel(
|
||||||
|
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (prevVis === 'private' && nextVis === 'club') {
|
if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) {
|
||||||
const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
|
const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
|
||||||
selectedGraphId,
|
selectedGraphId,
|
||||||
{ targetVisibility: 'club' },
|
{ targetVisibility: nextVis },
|
||||||
)
|
)
|
||||||
const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
|
const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
|
||||||
if (privateExercises.length > 0) {
|
if (promotionExercises.length > 0) {
|
||||||
const titles = privateExercises
|
const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis
|
||||||
|
const titles = promotionExercises
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map((ex) => `• ${ex.title || `Übung #${ex.id}`}`)
|
.map((ex) => `• ${ex.title || `Übung #${ex.id}`}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
const more =
|
const more =
|
||||||
privateExercises.length > 8
|
promotionExercises.length > 8
|
||||||
? `\n… und ${privateExercises.length - 8} weitere`
|
? `\n… und ${promotionExercises.length - 8} weitere`
|
||||||
: ''
|
: ''
|
||||||
const promote = window.confirm(
|
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) {
|
if (promote) {
|
||||||
const clubId = resolvePromoteClubId()
|
let clubId = null
|
||||||
if (!clubId) {
|
if (nextVis === 'club') {
|
||||||
throw new Error(
|
clubId = resolvePromoteClubId()
|
||||||
'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.',
|
if (!clubId) {
|
||||||
)
|
throw new Error(
|
||||||
} else {
|
'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.',
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub'
|
||||||
import ExercisePickerModal from './ExercisePickerModal'
|
import ExercisePickerModal from './ExercisePickerModal'
|
||||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||||
import ProgressionSlotCard from './ProgressionSlotCard'
|
import ProgressionSlotCard from './ProgressionSlotCard'
|
||||||
|
|
@ -85,6 +87,7 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
|
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
|
||||||
|
const { user } = useAuth()
|
||||||
const [graphMeta, setGraphMeta] = useState(null)
|
const [graphMeta, setGraphMeta] = useState(null)
|
||||||
const [draft, setDraft] = useState(null)
|
const [draft, setDraft] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
@ -880,9 +883,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
setSlotQuickSaving(true)
|
setSlotQuickSaving(true)
|
||||||
setSlotQuickError('')
|
setSlotQuickError('')
|
||||||
try {
|
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, {
|
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, {
|
||||||
visibility: graphMeta?.visibility || 'private',
|
visibility: graphVis,
|
||||||
clubId: graphMeta?.club_id ?? null,
|
clubId: graphClubId,
|
||||||
})
|
})
|
||||||
const created = await api.createExercise(payload)
|
const created = await api.createExercise(payload)
|
||||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user