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 fastapi import HTTPException
from pydantic import BaseModel, Field 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_exercise_profiles import PlanningTargetProfile
from planning_path_qa_pipeline import run_multistage_path_qa from planning_path_qa_pipeline import run_multistage_path_qa
from planning_path_rematch import ( from planning_path_rematch import (
@ -263,6 +267,90 @@ def _build_path_target_profile(
return target, query_intent_summary, intent 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( def _slot_assignments_by_major_index(
assignments: Optional[List[EvaluateStepPayload]], assignments: Optional[List[EvaluateStepPayload]],
) -> Dict[int, EvaluateStepPayload]: ) -> Dict[int, EvaluateStepPayload]:
@ -280,16 +368,32 @@ def _path_step_from_slot_assignment(
assignment: EvaluateStepPayload, assignment: EvaluateStepPayload,
stage_spec: StageSpecArtifact, stage_spec: StageSpecArtifact,
major_step: Optional[MajorStep], major_step: Optional[MajorStep],
tenant: Optional[TenantContext] = None,
progression_graph_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Bestehende Slot-Zuordnung aus dem Graph-Editor (nach KI-Anlage) übernehmen.""" """Bestehende Slot-Zuordnung aus dem Graph-Editor (nach KI-Anlage) übernehmen."""
eid = int(assignment.exercise_id) eid = int(assignment.exercise_id)
cur.execute( 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,), (eid,),
) )
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None 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 "") title = (assignment.title or row.get("title") or "").strip() or str(row.get("title") or "")
step = { step = {
"exercise_id": eid, "exercise_id": eid,
@ -366,6 +470,7 @@ def _run_path_step_retrieval(
path_context_note: Optional[str] = None, path_context_note: Optional[str] = None,
path_primary_topic: Optional[str] = None, path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[List[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]: ) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
step_query = step_query_override or step_retrieval_query( step_query = step_query_override or step_retrieval_query(
semantic_brief, goal_query, step_index, max_steps semantic_brief, goal_query, step_index, max_steps
@ -469,13 +574,10 @@ def _run_path_step_retrieval(
else: else:
weights = apply_path_retrieval_weights(semantic_brief) weights = apply_path_retrieval_weights(semantic_brief)
profile_id = tenant.profile_id vis_sql, vis_params = _planning_visibility_sql(
role = tenant.global_role cur,
vis_sql, vis_params = library_content_visibility_sql( tenant,
alias="e", progression_graph_id,
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
) )
hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval( hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval(
@ -488,6 +590,7 @@ def _run_path_step_retrieval(
intent=intent, intent=intent,
intent_weights=weights, intent_weights=weights,
pack=pack, pack=pack,
supplemental_exercise_ids=supplemental_exercise_ids,
) )
hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32]) hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32])
return hits, target_profile, query_intent_summary, intent return hits, target_profile, query_intent_summary, intent
@ -506,6 +609,7 @@ def _make_bridge_search_fn(
planned_ids: List[int], planned_ids: List[int],
path_target_profile: PlanningTargetProfile, path_target_profile: PlanningTargetProfile,
path_intent: str, path_intent: str,
supplemental_exercise_ids: Optional[List[int]] = None,
) -> Callable[..., List[Dict[str, Any]]]: ) -> Callable[..., List[Dict[str, Any]]]:
def _bridge_search( def _bridge_search(
step_a: Dict[str, Any], step_a: Dict[str, Any],
@ -529,6 +633,7 @@ def _make_bridge_search_fn(
step_a=step_a, step_a=step_a,
step_b=step_b, step_b=step_b,
path_target_profile=path_target_profile, path_target_profile=path_target_profile,
supplemental_exercise_ids=supplemental_exercise_ids,
path_intent=path_intent, path_intent=path_intent,
) )
gated = [ gated = [
@ -740,6 +845,7 @@ def _match_roadmap_slot(
path_context_note=path_context_note, path_context_note=path_context_note,
path_primary_topic=path_primary or None, path_primary_topic=path_primary or None,
path_technique_excludes=path_tech_excludes or None, path_technique_excludes=path_tech_excludes or None,
supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body),
) )
hit = _pick_best_path_hit( hit = _pick_best_path_hit(
@ -933,6 +1039,8 @@ def _build_steps_roadmap_first(
assignment=assignments[major_idx], assignment=assignments[major_idx],
stage_spec=stage_spec, stage_spec=stage_spec,
major_step=majors_by_index.get(major_idx), major_step=majors_by_index.get(major_idx),
tenant=tenant,
progression_graph_id=body.progression_graph_id,
) )
if pinned: if pinned:
steps.append(pinned) steps.append(pinned)
@ -1471,6 +1579,7 @@ def suggest_progression_path(
semantic_brief=semantic_brief, semantic_brief=semantic_brief,
path_target_profile=path_target_profile, path_target_profile=path_target_profile,
path_intent=path_intent, path_intent=path_intent,
supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body),
) )
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
@ -1531,6 +1640,7 @@ def suggest_progression_path(
planned_ids=planned_ids, planned_ids=planned_ids,
path_target_profile=path_target_profile, path_target_profile=path_target_profile,
path_intent=path_intent, path_intent=path_intent,
supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body),
) )
steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises( steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises(
cur, cur,

View File

@ -58,6 +58,52 @@ def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> L
return out 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( def fetch_all_visible_exercise_rows(
cur, cur,
*, *,
@ -438,6 +484,7 @@ def run_multistage_planning_retrieval(
intent: str, intent: str,
intent_weights: Mapping[str, float], intent_weights: Mapping[str, float],
pack: Mapping[str, Any], pack: Mapping[str, Any],
supplemental_exercise_ids: Optional[Sequence[int]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]: ) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking).""" """Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
rows = fetch_all_visible_exercise_rows( rows = fetch_all_visible_exercise_rows(
@ -447,6 +494,14 @@ def run_multistage_planning_retrieval(
query=pack.get("retrieval_query") or query, query=pack.get("retrieval_query") or query,
exercise_kind_any=exercise_kind_any, 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( hits, skills_by_ex = rank_visible_library_hits(
cur, cur,
rows, rows,
@ -482,8 +537,10 @@ def profile_preselect_rows(
__all__ = [ __all__ = [
"fetch_all_visible_exercise_rows", "fetch_all_visible_exercise_rows",
"fetch_exercise_rows_by_ids",
"fetch_retrieval_candidate_rows", "fetch_retrieval_candidate_rows",
"hybrid_score_planning_hits", "hybrid_score_planning_hits",
"merge_supplemental_exercise_rows",
"profile_preselect_rows", "profile_preselect_rows",
"rank_visible_library_hits", "rank_visible_library_hits",
"run_multistage_planning_retrieval", "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. Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral. AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
""" """
import json
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -256,6 +257,127 @@ def get_progression_graph(
return row 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) @router.post("/exercise-progression-graphs", status_code=201)
def create_progression_graph( def create_progression_graph(
body: ProgressionGraphCreate, body: ProgressionGraphCreate,

View File

@ -108,6 +108,57 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params 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( def club_library_visibility_sql(
*, *,
alias: str, alias: str,

View File

@ -2,7 +2,12 @@
import pytest import pytest
from fastapi import HTTPException 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(): 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] 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(): def test_library_visibility_sql_user_with_active_club_includes_club_branch():
sql, params = library_content_visibility_sql( sql, params = library_content_visibility_sql(
alias="t", alias="t",

View File

@ -521,6 +521,16 @@ export async function getExerciseProgressionGraph(id, { includeEdges = false } =
return request(`/api/exercise-progression-graphs/${id}${q}`) 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) { export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', { return request('/api/exercise-progression-graphs', {
method: 'POST', method: 'POST',

View File

@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel' import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import ProgressionGraphEditor from './ProgressionGraphEditor' import ProgressionGraphEditor from './ProgressionGraphEditor'
import ProgressionGraphListCard from './ProgressionGraphListCard' import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' 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 () => { const handleSaveMeta = async () => {
if (!selectedGraphId) return if (!selectedGraphId) return
const name = metaName.trim() const name = metaName.trim()
@ -233,8 +241,50 @@ function ExerciseProgressionGraphPanel(
alert('Name ist Pflicht') alert('Name ist Pflicht')
return return
} }
const prevGraph = graphs.find((x) => x.id === selectedGraphId)
const prevVis = (prevGraph?.visibility || 'private').trim().toLowerCase()
const nextVis = (metaVisibility || 'private').trim().toLowerCase()
setBusy(true) setBusy(true)
try { 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, { await api.updateExerciseProgressionGraph(selectedGraphId, {
name, name,
description: metaDescription.trim() || null, description: metaDescription.trim() || null,