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
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:
parent
7203c871fc
commit
b464047c3a
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user