Enhance Exercise Progression Graph Functionality and Visibility Logic
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s

- Introduced new functions for handling exercise visibility in progression graphs, including `library_content_visibility_for_progression_graph_sql` to manage visibility based on graph context.
- Added `_supplemental_exercise_ids_from_body` to extract exercise IDs from request bodies, improving data handling in path suggestions.
- Implemented visibility promotion candidate retrieval in the API, allowing for the identification of private exercises that need visibility adjustments when promoting graph visibility.
- Enhanced existing SQL queries and retrieval functions to incorporate new visibility logic, ensuring accurate exercise visibility based on user roles and graph settings.
- Updated frontend components to support visibility promotion workflows, including user prompts for managing private exercises during graph visibility changes.
- Added tests to validate new visibility logic and ensure robustness in exercise retrieval and promotion processes.
This commit is contained in:
Lars 2026-06-11 12:10:46 +02:00
parent 7203c871fc
commit b464047c3a
7 changed files with 447 additions and 11 deletions

View File

@ -11,7 +11,11 @@ from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
from tenant_context import TenantContext, library_content_visibility_sql
from tenant_context import (
TenantContext,
library_content_visibility_for_progression_graph_sql,
library_content_visibility_sql,
)
from planning_exercise_profiles import PlanningTargetProfile
from planning_path_qa_pipeline import run_multistage_path_qa
from planning_path_rematch import (
@ -263,6 +267,90 @@ def _build_path_target_profile(
return target, query_intent_summary, intent
def _supplemental_exercise_ids_from_body(body: ProgressionPathSuggestRequest) -> List[int]:
"""Verankerte Graph-Slots immer im Retriever-Kandidatenpool halten."""
ids: List[int] = []
for coll in (body.slot_assignments, body.evaluate_steps):
for raw in coll or []:
if raw.exercise_id is not None:
try:
eid = int(raw.exercise_id)
except (TypeError, ValueError):
continue
if eid > 0:
ids.append(eid)
return list(dict.fromkeys(ids))
def _planning_visibility_sql(
cur,
tenant: TenantContext,
progression_graph_id: Optional[int],
) -> Tuple[str, List[Any]]:
if progression_graph_id and int(progression_graph_id) > 0:
cur.execute(
"SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s",
(int(progression_graph_id),),
)
grow = cur.fetchone()
if grow:
g_club = grow.get("club_id")
return library_content_visibility_for_progression_graph_sql(
alias="e",
profile_id=tenant.profile_id,
role=tenant.global_role,
effective_club_id=tenant.effective_club_id,
graph_visibility=str(grow.get("visibility") or "private"),
graph_club_id=int(g_club) if g_club is not None else None,
)
return library_content_visibility_sql(
alias="e",
profile_id=tenant.profile_id,
role=tenant.global_role,
effective_club_id=tenant.effective_club_id,
)
def _exercise_allowed_in_progression_graph(
exercise_row: Mapping[str, Any],
*,
graph_visibility: str,
graph_club_id: Optional[int],
profile_id: int,
role: str,
) -> bool:
"""Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt."""
from club_tenancy import is_platform_admin
ex_vis = (exercise_row.get("visibility") or "private").strip().lower()
gvis = (graph_visibility or "private").strip().lower()
if gvis == "private":
if ex_vis == "official":
return True
if ex_vis == "club":
return True
if ex_vis == "private":
if is_platform_admin(role):
return True
try:
return int(exercise_row.get("created_by") or 0) == int(profile_id)
except (TypeError, ValueError):
return False
return False
if gvis == "club":
if ex_vis == "official":
return True
if ex_vis != "club":
return False
ex_club = exercise_row.get("club_id")
if ex_club is None:
return False
if graph_club_id is None:
return True
return int(ex_club) == int(graph_club_id)
return ex_vis == "official"
def _slot_assignments_by_major_index(
assignments: Optional[List[EvaluateStepPayload]],
) -> Dict[int, EvaluateStepPayload]:
@ -280,16 +368,32 @@ def _path_step_from_slot_assignment(
assignment: EvaluateStepPayload,
stage_spec: StageSpecArtifact,
major_step: Optional[MajorStep],
tenant: Optional[TenantContext] = None,
progression_graph_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""Bestehende Slot-Zuordnung aus dem Graph-Editor (nach KI-Anlage) übernehmen."""
eid = int(assignment.exercise_id)
cur.execute(
"SELECT id, title, summary FROM exercises WHERE id = %s",
"SELECT id, title, summary, visibility, club_id, created_by FROM exercises WHERE id = %s",
(eid,),
)
row = cur.fetchone()
if not row:
return None
if tenant and progression_graph_id:
cur.execute(
"SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s",
(int(progression_graph_id),),
)
grow = cur.fetchone()
if grow and not _exercise_allowed_in_progression_graph(
row,
graph_visibility=str(grow.get("visibility") or "private"),
graph_club_id=int(grow["club_id"]) if grow.get("club_id") is not None else None,
profile_id=tenant.profile_id,
role=tenant.global_role,
):
return None
title = (assignment.title or row.get("title") or "").strip() or str(row.get("title") or "")
step = {
"exercise_id": eid,
@ -366,6 +470,7 @@ def _run_path_step_retrieval(
path_context_note: Optional[str] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[List[str]] = None,
supplemental_exercise_ids: Optional[List[int]] = None,
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
step_query = step_query_override or step_retrieval_query(
semantic_brief, goal_query, step_index, max_steps
@ -469,13 +574,10 @@ def _run_path_step_retrieval(
else:
weights = apply_path_retrieval_weights(semantic_brief)
profile_id = tenant.profile_id
role = tenant.global_role
vis_sql, vis_params = library_content_visibility_sql(
alias="e",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
vis_sql, vis_params = _planning_visibility_sql(
cur,
tenant,
progression_graph_id,
)
hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval(
@ -488,6 +590,7 @@ def _run_path_step_retrieval(
intent=intent,
intent_weights=weights,
pack=pack,
supplemental_exercise_ids=supplemental_exercise_ids,
)
hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32])
return hits, target_profile, query_intent_summary, intent
@ -506,6 +609,7 @@ def _make_bridge_search_fn(
planned_ids: List[int],
path_target_profile: PlanningTargetProfile,
path_intent: str,
supplemental_exercise_ids: Optional[List[int]] = None,
) -> Callable[..., List[Dict[str, Any]]]:
def _bridge_search(
step_a: Dict[str, Any],
@ -529,6 +633,7 @@ def _make_bridge_search_fn(
step_a=step_a,
step_b=step_b,
path_target_profile=path_target_profile,
supplemental_exercise_ids=supplemental_exercise_ids,
path_intent=path_intent,
)
gated = [
@ -740,6 +845,7 @@ def _match_roadmap_slot(
path_context_note=path_context_note,
path_primary_topic=path_primary or None,
path_technique_excludes=path_tech_excludes or None,
supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body),
)
hit = _pick_best_path_hit(
@ -933,6 +1039,8 @@ def _build_steps_roadmap_first(
assignment=assignments[major_idx],
stage_spec=stage_spec,
major_step=majors_by_index.get(major_idx),
tenant=tenant,
progression_graph_id=body.progression_graph_id,
)
if pinned:
steps.append(pinned)
@ -1471,6 +1579,7 @@ def suggest_progression_path(
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body),
)
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
@ -1531,6 +1640,7 @@ def suggest_progression_path(
planned_ids=planned_ids,
path_target_profile=path_target_profile,
path_intent=path_intent,
supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body),
)
steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises(
cur,

View File

@ -58,6 +58,52 @@ def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> L
return out
def fetch_exercise_rows_by_ids(
cur,
exercise_ids: Sequence[int],
*,
vis_sql: str,
vis_params: Sequence[Any],
) -> List[Dict[str, Any]]:
"""Lädt konkrete Übungen nach, wenn sie im Graph/Slot verankert sind (Pin-Sicherheit)."""
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
if not ids:
return []
ph = ",".join(["%s"] * len(ids))
sql = f"""
SELECT e.id, e.title, e.summary, e.method_archetype,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name,
0.0::float AS ft_rank
FROM exercises e
WHERE e.id IN ({ph})
AND ({vis_sql})
AND COALESCE(e.status, '') <> %s
"""
params: List[Any] = list(ids) + list(vis_params) + ["archived"]
cur.execute(sql, params)
return [dict(r) for r in cur.fetchall()]
def merge_supplemental_exercise_rows(
rows: Sequence[Dict[str, Any]],
supplemental: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
seen = {int(r["id"]) for r in rows if r.get("id") is not None}
out = list(rows)
for row in supplemental:
rid = int(row["id"])
if rid not in seen:
seen.add(rid)
out.append(dict(row))
return out
def fetch_all_visible_exercise_rows(
cur,
*,
@ -438,6 +484,7 @@ def run_multistage_planning_retrieval(
intent: str,
intent_weights: Mapping[str, float],
pack: Mapping[str, Any],
supplemental_exercise_ids: Optional[Sequence[int]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
rows = fetch_all_visible_exercise_rows(
@ -447,6 +494,14 @@ def run_multistage_planning_retrieval(
query=pack.get("retrieval_query") or query,
exercise_kind_any=exercise_kind_any,
)
if supplemental_exercise_ids:
extra = fetch_exercise_rows_by_ids(
cur,
supplemental_exercise_ids,
vis_sql=vis_sql,
vis_params=vis_params,
)
rows = merge_supplemental_exercise_rows(rows, extra)
hits, skills_by_ex = rank_visible_library_hits(
cur,
rows,
@ -482,8 +537,10 @@ def profile_preselect_rows(
__all__ = [
"fetch_all_visible_exercise_rows",
"fetch_exercise_rows_by_ids",
"fetch_retrieval_candidate_rows",
"hybrid_score_planning_hits",
"merge_supplemental_exercise_rows",
"profile_preselect_rows",
"rank_visible_library_hits",
"run_multistage_planning_retrieval",

View File

@ -3,6 +3,7 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032034.
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
"""
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
@ -256,6 +257,127 @@ def get_progression_graph(
return row
def _exercise_ids_from_planning_roadmap(artifact: Optional[Dict[str, Any]]) -> set[int]:
ids: set[int] = set()
if not artifact or not isinstance(artifact, dict):
return ids
for slot in artifact.get("slot_contents") or []:
if not isinstance(slot, dict):
continue
primary = slot.get("primary") if isinstance(slot.get("primary"), dict) else {}
if primary.get("kind") == "library" and primary.get("exercise_id") is not None:
try:
ids.add(int(primary["exercise_id"]))
except (TypeError, ValueError):
pass
for sib in slot.get("siblings") or []:
if not isinstance(sib, dict):
continue
if sib.get("kind") == "library" and sib.get("exercise_id") is not None:
try:
ids.add(int(sib["exercise_id"]))
except (TypeError, ValueError):
pass
return ids
def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
ids: set[int] = set()
cur.execute(
"""
SELECT from_exercise_id, to_exercise_id
FROM exercise_progression_edges
WHERE graph_id = %s
""",
(graph_id,),
)
for row in cur.fetchall():
for key in ("from_exercise_id", "to_exercise_id"):
raw = row.get(key)
if raw is not None:
ids.add(int(raw))
cur.execute(
"SELECT planning_roadmap FROM exercise_progression_graphs WHERE id = %s",
(graph_id,),
)
prow = cur.fetchone()
if prow and prow.get("planning_roadmap"):
art = prow["planning_roadmap"]
if isinstance(art, str):
try:
art = json.loads(art)
except json.JSONDecodeError:
art = None
ids |= _exercise_ids_from_planning_roadmap(art if isinstance(art, dict) else None)
return ids
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
def list_visibility_promotion_candidates(
graph_id: int,
target_visibility: str = Query(default="club", pattern="^(club|official)$"),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
"""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
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":
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_visibility,
"exercises": [],
}
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
if not ref_ids:
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_visibility,
"exercises": [],
}
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'
ORDER BY title
""",
list(ref_ids),
)
exercises = []
for ex in cur.fetchall():
exd = r2d(ex)
if not library_content_visible_to_profile(
cur,
profile_id,
role,
exd,
):
continue
exercises.append(
{
"id": exd["id"],
"title": exd.get("title"),
"visibility": exd.get("visibility"),
}
)
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_visibility,
"exercises": exercises,
}
@router.post("/exercise-progression-graphs", status_code=201)
def create_progression_graph(
body: ProgressionGraphCreate,

View File

@ -108,6 +108,57 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params
def library_content_visibility_for_progression_graph_sql(
*,
alias: str,
profile_id: int,
role: str,
effective_club_id: Optional[int],
graph_visibility: str,
graph_club_id: Optional[int] = None,
) -> tuple[str, List[Any]]:
"""
Übungs-Sichtbarkeit für Progressionsgraph-Match/Planung.
- private Graph: private (eigene) + Verein + offiziell volle Nutzer-Bibliothek
- club Graph: nur Verein (aktiver Graph-Verein) + offiziell
- official Graph: nur offiziell
"""
gvis = (graph_visibility or "private").strip().lower()
if gvis == "private":
return library_content_visibility_sql(
alias=alias,
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
)
if gvis == "club":
parts: List[str] = [f"{alias}.visibility = 'official'"]
params: List[Any] = []
club_id = graph_club_id if graph_club_id is not None else effective_club_id
if club_id is not None:
plat = is_platform_admin(role)
if plat:
parts.append(f"({alias}.visibility = 'club' AND {alias}.club_id = %s)")
params.append(int(club_id))
else:
parts.append(
f"""(
{alias}.visibility = 'club'
AND {alias}.club_id = %s
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = {alias}.club_id
AND cm.status = 'active'
)
)"""
)
params.extend([int(club_id), profile_id])
return "(" + " OR ".join(parts) + ")", params
return f"({alias}.visibility = 'official')", []
def club_library_visibility_sql(
*,
alias: str,

View File

@ -2,7 +2,12 @@
import pytest
from fastapi import HTTPException
from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context
from tenant_context import (
library_content_visibility_for_progression_graph_sql,
library_content_visibility_sql,
parse_active_club_header,
resolve_tenant_context,
)
def test_library_visibility_sql_platform_admin_restricts_club_by_membership():
@ -41,6 +46,37 @@ def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branc
assert params == [42]
def test_progression_graph_visibility_sql_private_matches_library():
base_sql, base_params = library_content_visibility_sql(
alias="e", profile_id=5, role="trainer", effective_club_id=12
)
graph_sql, graph_params = library_content_visibility_for_progression_graph_sql(
alias="e",
profile_id=5,
role="trainer",
effective_club_id=12,
graph_visibility="private",
graph_club_id=None,
)
assert graph_sql == base_sql
assert graph_params == base_params
def test_progression_graph_visibility_sql_club_excludes_private():
sql, params = library_content_visibility_for_progression_graph_sql(
alias="e",
profile_id=5,
role="trainer",
effective_club_id=12,
graph_visibility="club",
graph_club_id=12,
)
assert "official" in sql
assert "visibility = 'club'" in sql
assert "visibility = 'private'" not in sql
assert 12 in params
def test_library_visibility_sql_user_with_active_club_includes_club_branch():
sql, params = library_content_visibility_sql(
alias="t",

View File

@ -521,6 +521,16 @@ export async function getExerciseProgressionGraph(id, { includeEdges = false } =
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function getProgressionGraphVisibilityPromotionCandidates(
graphId,
{ targetVisibility = 'club' } = {},
) {
const q = new URLSearchParams({ target_visibility: targetVisibility })
return request(
`/api/exercise-progression-graphs/${graphId}/visibility-promotion-candidates?${q}`,
)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',

View File

@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import ProgressionGraphEditor from './ProgressionGraphEditor'
import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
@ -226,6 +226,14 @@ function ExerciseProgressionGraphPanel(
}
}
const resolvePromoteClubId = () => {
const g = graphs.find((x) => x.id === selectedGraphId)
if (g?.club_id != null) return Number(g.club_id)
const memberships = activeClubMemberships(user?.clubs)
const active = memberships.find((c) => c.is_active) || memberships[0]
return active?.club_id != null ? Number(active.club_id) : null
}
const handleSaveMeta = async () => {
if (!selectedGraphId) return
const name = metaName.trim()
@ -233,8 +241,50 @@ function ExerciseProgressionGraphPanel(
alert('Name ist Pflicht')
return
}
const prevGraph = graphs.find((x) => x.id === selectedGraphId)
const prevVis = (prevGraph?.visibility || 'private').trim().toLowerCase()
const nextVis = (metaVisibility || 'private').trim().toLowerCase()
setBusy(true)
try {
if (prevVis === 'private' && nextVis === 'club') {
const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
selectedGraphId,
{ targetVisibility: 'club' },
)
const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
if (privateExercises.length > 0) {
const titles = privateExercises
.slice(0, 8)
.map((ex) => `${ex.title || `Übung #${ex.id}`}`)
.join('\n')
const more =
privateExercises.length > 8
? `\n… und ${privateExercises.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?`,
)
if (promote) {
const clubId = resolvePromoteClubId()
if (!clubId) {
alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.')
} 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')
}
}
}
}
}
await api.updateExerciseProgressionGraph(selectedGraphId, {
name,
description: metaDescription.trim() || null,