From b464047c3aa3a7655758ae0503321425a9751db1 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Jun 2026 12:10:46 +0200 Subject: [PATCH] Enhance Exercise Progression Graph Functionality and Visibility Logic - 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. --- backend/planning_exercise_path_builder.py | 128 ++++++++++++++++-- backend/planning_exercise_retrieval.py | 57 ++++++++ .../routers/exercise_progression_graphs.py | 122 +++++++++++++++++ backend/tenant_context.py | 51 +++++++ backend/tests/test_access_layer.py | 38 +++++- frontend/src/api/exercises.js | 10 ++ .../ExerciseProgressionGraphPanel.jsx | 52 ++++++- 7 files changed, 447 insertions(+), 11 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 9316f41..8b6157d 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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, diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index 6b748b1..da71d8a 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -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", diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 16c1380..9e4edaa 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -3,6 +3,7 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034. 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, diff --git a/backend/tenant_context.py b/backend/tenant_context.py index 175221e..d3ab1ae 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -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, diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index 915c0ee..8169dc7 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -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", diff --git a/frontend/src/api/exercises.js b/frontend/src/api/exercises.js index 4eee53d..4e72274 100644 --- a/frontend/src/api/exercises.js +++ b/frontend/src/api/exercises.js @@ -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', diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index a0a2988..2d02149 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -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,