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 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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user