2026-04-30 11:47:50 +02:00
|
|
|
|
"""
|
2026-05-03 18:07:52 +02:00
|
|
|
|
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
|
|
|
|
|
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
|
2026-05-16 10:53:00 +02:00
|
|
|
|
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
|
2026-04-30 11:47:50 +02:00
|
|
|
|
"""
|
2026-06-11 12:10:46 +02:00
|
|
|
|
import json
|
2026-06-14 06:44:12 +02:00
|
|
|
|
from typing import Any, Dict, List, Mapping, Optional
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
2026-05-03 18:07:52 +02:00
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
2026-04-30 11:47:50 +02:00
|
|
|
|
from psycopg2 import IntegrityError
|
2026-06-10 07:25:57 +02:00
|
|
|
|
from psycopg2.extras import Json
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
from db import get_db, get_cursor, r2d
|
2026-06-10 07:25:57 +02:00
|
|
|
|
from progression_graph_planning_artifact import normalize_planning_roadmap_payload
|
2026-05-05 22:05:10 +02:00
|
|
|
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
|
|
|
|
|
from club_tenancy import (
|
2026-05-16 10:53:00 +02:00
|
|
|
|
assert_library_content_deletable,
|
|
|
|
|
|
assert_library_content_editable,
|
|
|
|
|
|
assert_library_content_governance_transition,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
assert_valid_governance_visibility,
|
2026-06-14 06:44:12 +02:00
|
|
|
|
is_platform_admin,
|
2026-05-05 22:09:25 +02:00
|
|
|
|
library_content_visible_to_profile,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
from routers.training_planning import _has_planning_role
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api", tags=["exercises"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProgressionGraphCreate(BaseModel):
|
|
|
|
|
|
name: str = Field(..., min_length=1, max_length=200)
|
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
|
visibility: str = Field(default="private", pattern="^(private|club|official)$")
|
|
|
|
|
|
club_id: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProgressionGraphUpdate(BaseModel):
|
|
|
|
|
|
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
|
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
|
|
|
|
|
club_id: Optional[int] = None
|
2026-06-10 07:25:57 +02:00
|
|
|
|
planning_roadmap: Optional[Dict[str, Any]] = None
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProgressionEdgeCreate(BaseModel):
|
|
|
|
|
|
from_exercise_id: int = Field(..., gt=0)
|
|
|
|
|
|
to_exercise_id: int = Field(..., gt=0)
|
2026-05-03 18:07:52 +02:00
|
|
|
|
from_exercise_variant_id: Optional[int] = Field(default=None)
|
|
|
|
|
|
to_exercise_variant_id: Optional[int] = Field(default=None)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
edge_type: str = Field(default="next_exercise", min_length=1, max_length=50)
|
|
|
|
|
|
notes: Optional[str] = Field(None, max_length=4000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProgressionEdgeUpdate(BaseModel):
|
|
|
|
|
|
notes: Optional[str] = Field(None, max_length=4000)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 18:07:52 +02:00
|
|
|
|
class SequenceStep(BaseModel):
|
|
|
|
|
|
exercise_id: int = Field(..., gt=0)
|
|
|
|
|
|
variant_id: Optional[int] = Field(default=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProgressionSequenceCreate(BaseModel):
|
|
|
|
|
|
steps: List[SequenceStep] = Field(..., min_length=2)
|
|
|
|
|
|
segment_notes: Optional[List[Optional[str]]] = None
|
2026-06-10 07:25:57 +02:00
|
|
|
|
planning_roadmap: Optional[Dict[str, Any]] = None
|
2026-05-03 18:07:52 +02:00
|
|
|
|
"""Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
|
|
|
|
|
|
|
|
|
|
|
|
@model_validator(mode="after")
|
|
|
|
|
|
def check_segment_notes_len(self):
|
|
|
|
|
|
if self.segment_notes is not None and len(self.segment_notes) != len(self.steps) - 1:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"segment_notes muss genau {len(self.steps) - 1} Einträge haben (len(steps)-1)"
|
|
|
|
|
|
)
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EdgeIdsBatch(BaseModel):
|
|
|
|
|
|
edge_ids: List[int] = Field(..., min_length=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
|
_EDGE_SELECT = """
|
2026-05-03 18:07:52 +02:00
|
|
|
|
SELECT e.id, e.graph_id,
|
|
|
|
|
|
e.from_exercise_id, e.from_exercise_variant_id,
|
|
|
|
|
|
e.to_exercise_id, e.to_exercise_variant_id,
|
|
|
|
|
|
e.edge_type, e.notes, e.created_at,
|
|
|
|
|
|
ef.title AS from_exercise_title, et.title AS to_exercise_title,
|
|
|
|
|
|
vf.variant_name AS from_variant_name, vt.variant_name AS to_variant_name
|
2026-04-30 11:47:50 +02:00
|
|
|
|
FROM exercise_progression_edges e
|
|
|
|
|
|
JOIN exercises ef ON ef.id = e.from_exercise_id
|
|
|
|
|
|
JOIN exercises et ON et.id = e.to_exercise_id
|
2026-05-03 18:07:52 +02:00
|
|
|
|
LEFT JOIN exercise_variants vf ON vf.id = e.from_exercise_variant_id
|
|
|
|
|
|
LEFT JOIN exercise_variants vt ON vt.id = e.to_exercise_variant_id
|
2026-04-30 11:47:50 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 22:05:10 +02:00
|
|
|
|
def _graph_row(cur, graph_id: int) -> dict:
|
2026-04-30 11:47:50 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT * FROM exercise_progression_graphs WHERE id = %s",
|
|
|
|
|
|
(graph_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
r = cur.fetchone()
|
|
|
|
|
|
if not r:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden")
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return r2d(r)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_graph_readable(cur, row: dict, profile_id: int, role: str) -> None:
|
|
|
|
|
|
vis = (row.get("visibility") or "private").strip().lower()
|
|
|
|
|
|
cid = row.get("club_id")
|
|
|
|
|
|
if cid is not None:
|
|
|
|
|
|
cid = int(cid)
|
|
|
|
|
|
cr = row.get("created_by")
|
|
|
|
|
|
if cr is not None:
|
|
|
|
|
|
cr = int(cr)
|
2026-05-05 22:09:25 +02:00
|
|
|
|
if not library_content_visible_to_profile(cur, profile_id, vis, cid, cr, role):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
|
2026-05-16 10:53:00 +02:00
|
|
|
|
assert_library_content_editable(cur, profile_id, role, row)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 07:25:57 +02:00
|
|
|
|
def _persist_graph_planning_roadmap(cur, graph_id: int, raw: Optional[Dict[str, Any]]) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
normalized = normalize_planning_roadmap_payload(raw)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE exercise_progression_graphs SET planning_roadmap = %s WHERE id = %s",
|
|
|
|
|
|
(Json(normalized) if normalized is not None else None, graph_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 22:05:10 +02:00
|
|
|
|
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
|
|
|
|
|
|
row = _graph_row(cur, graph_id)
|
|
|
|
|
|
_assert_graph_readable(cur, row, profile_id, role)
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_graph_write(cur, graph_id: int, profile_id: int, role: str) -> dict:
|
|
|
|
|
|
row = _graph_row(cur, graph_id)
|
|
|
|
|
|
_assert_graph_writable(cur, row, profile_id, role)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_exercises_exist(cur, *exercise_ids: int) -> None:
|
|
|
|
|
|
unique_ids = list(dict.fromkeys(exercise_ids))
|
|
|
|
|
|
if not unique_ids:
|
|
|
|
|
|
return
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"SELECT id FROM exercises WHERE id IN ({','.join(['%s'] * len(unique_ids))})",
|
|
|
|
|
|
tuple(unique_ids),
|
|
|
|
|
|
)
|
|
|
|
|
|
found = {r["id"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
|
|
|
|
|
|
missing = set(unique_ids) - found
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Unbekannte Übung(en): {sorted(missing)}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 18:07:52 +02:00
|
|
|
|
def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int]) -> None:
|
|
|
|
|
|
if variant_id is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT exercise_id FROM exercise_variants WHERE id = %s",
|
|
|
|
|
|
(variant_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Unbekannte Variante: {variant_id}")
|
|
|
|
|
|
ev_ex = row["exercise_id"] if isinstance(row, dict) else row[0]
|
|
|
|
|
|
if ev_ex != exercise_id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 06:44:12 +02:00
|
|
|
|
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."""
|
|
|
|
|
|
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 _assert_exercises_allowed_in_graph(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
profile_id: int,
|
|
|
|
|
|
role: str,
|
|
|
|
|
|
*exercise_ids: int,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""400 wenn eine Übung nicht zur Graph-Sichtbarkeit passt."""
|
|
|
|
|
|
row = _graph_row(cur, graph_id)
|
|
|
|
|
|
gvis = (row.get("visibility") or "private").strip().lower()
|
|
|
|
|
|
gclub_raw = row.get("club_id")
|
|
|
|
|
|
gclub = int(gclub_raw) if gclub_raw is not None else None
|
|
|
|
|
|
unique = list(dict.fromkeys(exercise_ids))
|
|
|
|
|
|
if not unique:
|
|
|
|
|
|
return
|
|
|
|
|
|
ph = ",".join(["%s"] * len(unique))
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph})",
|
|
|
|
|
|
tuple(unique),
|
|
|
|
|
|
)
|
|
|
|
|
|
by_id = {int(r2d(r)["id"]): r2d(r) for r in cur.fetchall()}
|
|
|
|
|
|
for eid in unique:
|
|
|
|
|
|
ex = by_id.get(int(eid))
|
|
|
|
|
|
if not ex:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if _exercise_allowed_in_progression_graph(
|
|
|
|
|
|
ex,
|
|
|
|
|
|
graph_visibility=gvis,
|
|
|
|
|
|
graph_club_id=gclub,
|
|
|
|
|
|
profile_id=profile_id,
|
|
|
|
|
|
role=role,
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
title = (ex.get("title") or "").strip() or f"#{eid}"
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=(
|
|
|
|
|
|
f"Übung „{title}“ (Sichtbarkeit: {ex.get('visibility') or 'private'}) "
|
|
|
|
|
|
f"passt nicht zum Progressionsgraphen ({gvis})."
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 18:07:52 +02:00
|
|
|
|
def _insert_edge_row(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
from_exercise_id: int,
|
|
|
|
|
|
from_variant_id: Optional[int],
|
|
|
|
|
|
to_exercise_id: int,
|
|
|
|
|
|
to_variant_id: Optional[int],
|
|
|
|
|
|
edge_type: str,
|
|
|
|
|
|
notes: Optional[str],
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO exercise_progression_edges (
|
|
|
|
|
|
graph_id,
|
|
|
|
|
|
from_exercise_id, from_exercise_variant_id,
|
|
|
|
|
|
to_exercise_id, to_exercise_variant_id,
|
|
|
|
|
|
edge_type, notes
|
|
|
|
|
|
)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
graph_id,
|
|
|
|
|
|
from_exercise_id,
|
|
|
|
|
|
from_variant_id,
|
|
|
|
|
|
to_exercise_id,
|
|
|
|
|
|
to_variant_id,
|
|
|
|
|
|
edge_type,
|
|
|
|
|
|
notes,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
new_id = cur.fetchone()["id"]
|
|
|
|
|
|
cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,))
|
|
|
|
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
|
@router.get("/exercise-progression-graphs")
|
2026-05-05 22:05:10 +02:00
|
|
|
|
def list_progression_graphs(tenant: TenantContext = Depends(get_tenant_context)):
|
|
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-09 10:55:58 +02:00
|
|
|
|
vis_sql, vis_params = library_content_visibility_sql(
|
|
|
|
|
|
alias="g",
|
|
|
|
|
|
profile_id=profile_id,
|
|
|
|
|
|
role=role,
|
|
|
|
|
|
effective_club_id=tenant.effective_club_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"""
|
|
|
|
|
|
SELECT g.*,
|
|
|
|
|
|
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
|
|
|
|
|
|
FROM exercise_progression_graphs g
|
|
|
|
|
|
WHERE ({vis_sql})
|
|
|
|
|
|
ORDER BY g.updated_at DESC NULLS LAST, g.name
|
|
|
|
|
|
""",
|
|
|
|
|
|
vis_params,
|
|
|
|
|
|
)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/exercise-progression-graphs/{graph_id}")
|
|
|
|
|
|
def get_progression_graph(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
include_edges: bool = Query(default=False),
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
row = _require_graph_read(cur, graph_id, profile_id, role)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
if include_edges:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
_EDGE_SELECT + " WHERE e.graph_id = %s ORDER BY e.id",
|
|
|
|
|
|
(graph_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
row["edges"] = [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 12:10:46 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 07:30:26 +02:00
|
|
|
|
def _graph_promotion_transition(graph_visibility: str, target_visibility: str) -> Optional[tuple[str, ...]]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Erlaubte Graph-Promotions und welche Übungs-Sichtbarkeiten mit angehoben werden müssen.
|
|
|
|
|
|
|
|
|
|
|
|
Returns None wenn kein Übungs-Promotion-Hinweis nötig.
|
|
|
|
|
|
"""
|
|
|
|
|
|
gvis = (graph_visibility or "private").strip().lower()
|
|
|
|
|
|
tvis = (target_visibility or "").strip().lower()
|
|
|
|
|
|
transitions: Dict[tuple[str, str], tuple[str, ...]] = {
|
|
|
|
|
|
("private", "club"): ("private",),
|
|
|
|
|
|
("private", "official"): ("private", "club"),
|
|
|
|
|
|
("club", "official"): ("private", "club"),
|
|
|
|
|
|
}
|
|
|
|
|
|
return transitions.get((gvis, tvis))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 12:10:46 +02:00
|
|
|
|
@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),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
2026-06-14 07:30:26 +02:00
|
|
|
|
Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
|
|
|
|
|
|
|
|
|
|
|
|
Unterstützt: private→club, private→official, club→official.
|
2026-06-11 12:10:46 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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()
|
2026-06-14 07:30:26 +02:00
|
|
|
|
target_vis = (target_visibility or "club").strip().lower()
|
|
|
|
|
|
need_vis = _graph_promotion_transition(graph_vis, target_vis)
|
|
|
|
|
|
if not need_vis:
|
2026-06-11 12:10:46 +02:00
|
|
|
|
return {
|
|
|
|
|
|
"graph_id": graph_id,
|
|
|
|
|
|
"graph_visibility": graph_vis,
|
2026-06-14 07:30:26 +02:00
|
|
|
|
"target_visibility": target_vis,
|
2026-06-11 12:10:46 +02:00
|
|
|
|
"exercises": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
|
|
|
|
|
|
if not ref_ids:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"graph_id": graph_id,
|
|
|
|
|
|
"graph_visibility": graph_vis,
|
2026-06-14 07:30:26 +02:00
|
|
|
|
"target_visibility": target_vis,
|
2026-06-11 12:10:46 +02:00
|
|
|
|
"exercises": [],
|
|
|
|
|
|
}
|
2026-06-14 07:30:26 +02:00
|
|
|
|
vis_placeholders = ",".join(["%s"] * len(need_vis))
|
2026-06-11 12:10:46 +02:00
|
|
|
|
ph = ",".join(["%s"] * len(ref_ids))
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"""
|
|
|
|
|
|
SELECT id, title, visibility, club_id, created_by
|
|
|
|
|
|
FROM exercises
|
|
|
|
|
|
WHERE id IN ({ph})
|
2026-06-14 07:30:26 +02:00
|
|
|
|
AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders})
|
2026-06-11 12:10:46 +02:00
|
|
|
|
ORDER BY title
|
|
|
|
|
|
""",
|
2026-06-14 07:30:26 +02:00
|
|
|
|
list(ref_ids) + list(need_vis),
|
2026-06-11 12:10:46 +02:00
|
|
|
|
)
|
|
|
|
|
|
exercises = []
|
|
|
|
|
|
for ex in cur.fetchall():
|
|
|
|
|
|
exd = r2d(ex)
|
|
|
|
|
|
if not library_content_visible_to_profile(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
profile_id,
|
2026-06-14 06:44:12 +02:00
|
|
|
|
(exd.get("visibility") or "private").strip().lower(),
|
|
|
|
|
|
exd.get("club_id"),
|
|
|
|
|
|
exd.get("created_by"),
|
2026-06-11 12:10:46 +02:00
|
|
|
|
role,
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
exercises.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": exd["id"],
|
|
|
|
|
|
"title": exd.get("title"),
|
|
|
|
|
|
"visibility": exd.get("visibility"),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"graph_id": graph_id,
|
|
|
|
|
|
"graph_visibility": graph_vis,
|
2026-06-14 07:30:26 +02:00
|
|
|
|
"target_visibility": target_vis,
|
2026-06-11 12:10:46 +02:00
|
|
|
|
"exercises": exercises,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
|
@router.post("/exercise-progression-graphs", status_code=201)
|
|
|
|
|
|
def create_progression_graph(
|
|
|
|
|
|
body: ProgressionGraphCreate,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
if not _has_planning_role(role):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen")
|
|
|
|
|
|
|
|
|
|
|
|
name = body.name.strip()
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="name ist Pflicht")
|
|
|
|
|
|
|
2026-05-05 22:05:10 +02:00
|
|
|
|
vis = (body.visibility or "private").strip().lower()
|
|
|
|
|
|
cid = body.club_id
|
|
|
|
|
|
if vis == "club":
|
|
|
|
|
|
if cid is None:
|
|
|
|
|
|
cid = tenant.effective_club_id
|
|
|
|
|
|
if cid is None:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
gov_club = cid if vis == "club" else None
|
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
assert_valid_governance_visibility(cur, profile_id, role, vis, gov_club)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
2026-05-05 22:05:10 +02:00
|
|
|
|
(name, body.description, vis, cid if vis == "club" else None, profile_id),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
)
|
|
|
|
|
|
gid = cur.fetchone()["id"]
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return get_progression_graph(gid, include_edges=False, tenant=tenant)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/exercise-progression-graphs/{graph_id}")
|
|
|
|
|
|
def update_progression_graph(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
body: ProgressionGraphUpdate,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
|
|
|
|
|
original = body.model_dump(exclude_unset=True)
|
|
|
|
|
|
if not original:
|
2026-04-30 11:47:50 +02:00
|
|
|
|
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
|
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
row = _require_graph_write(cur, graph_id, profile_id, role)
|
|
|
|
|
|
|
|
|
|
|
|
ex_vis = (row.get("visibility") or "private").strip().lower()
|
|
|
|
|
|
ex_cid_raw = row.get("club_id")
|
|
|
|
|
|
ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
next_vis = ex_vis
|
|
|
|
|
|
if "visibility" in original and original["visibility"] is not None:
|
|
|
|
|
|
v_raw = str(original["visibility"]).strip().lower()
|
|
|
|
|
|
if v_raw:
|
|
|
|
|
|
next_vis = v_raw
|
|
|
|
|
|
|
|
|
|
|
|
next_club = ex_cid
|
|
|
|
|
|
if "club_id" in original:
|
|
|
|
|
|
raw_c = original["club_id"]
|
|
|
|
|
|
if raw_c in (None, "", []):
|
|
|
|
|
|
next_club = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
next_club = int(raw_c)
|
|
|
|
|
|
|
|
|
|
|
|
if next_vis == "club":
|
|
|
|
|
|
if next_club is None:
|
|
|
|
|
|
next_club = tenant.effective_club_id
|
|
|
|
|
|
if next_club is None:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
gov_club = next_club if next_vis == "club" else None
|
2026-05-16 10:53:00 +02:00
|
|
|
|
assert_library_content_governance_transition(cur, profile_id, role, row, next_vis, gov_club)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
fields: List[str] = []
|
|
|
|
|
|
params: List[Any] = []
|
2026-05-05 22:05:10 +02:00
|
|
|
|
if "name" in original:
|
|
|
|
|
|
n = (original["name"] or "").strip()
|
2026-04-30 11:47:50 +02:00
|
|
|
|
if not n:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="name ist Pflicht")
|
|
|
|
|
|
fields.append("name = %s")
|
|
|
|
|
|
params.append(n)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
if "description" in original:
|
2026-04-30 11:47:50 +02:00
|
|
|
|
fields.append("description = %s")
|
2026-05-05 22:05:10 +02:00
|
|
|
|
params.append(original["description"])
|
|
|
|
|
|
|
|
|
|
|
|
vis_changed = next_vis != ex_vis
|
|
|
|
|
|
if "visibility" in original or vis_changed:
|
2026-04-30 11:47:50 +02:00
|
|
|
|
fields.append("visibility = %s")
|
2026-05-05 22:05:10 +02:00
|
|
|
|
params.append(next_vis)
|
|
|
|
|
|
|
|
|
|
|
|
if "club_id" in original or vis_changed:
|
2026-04-30 11:47:50 +02:00
|
|
|
|
fields.append("club_id = %s")
|
2026-05-05 22:05:10 +02:00
|
|
|
|
params.append(next_club if next_vis == "club" else None)
|
|
|
|
|
|
|
2026-06-10 07:25:57 +02:00
|
|
|
|
if "planning_roadmap" in original:
|
|
|
|
|
|
_persist_graph_planning_roadmap(cur, graph_id, original.get("planning_roadmap"))
|
|
|
|
|
|
|
|
|
|
|
|
if not fields and "planning_roadmap" not in original:
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
2026-06-10 07:25:57 +02:00
|
|
|
|
if fields:
|
|
|
|
|
|
fields.append("updated_at = NOW()")
|
|
|
|
|
|
params.append(graph_id)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
|
|
|
|
|
|
tuple(params),
|
|
|
|
|
|
)
|
|
|
|
|
|
elif "planning_roadmap" in original:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
|
|
|
|
|
|
(graph_id,),
|
|
|
|
|
|
)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/exercise-progression-graphs/{graph_id}")
|
2026-05-05 22:05:10 +02:00
|
|
|
|
def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
|
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-16 10:53:00 +02:00
|
|
|
|
row = _graph_row(cur, graph_id)
|
|
|
|
|
|
_assert_graph_readable(cur, row, profile_id, role)
|
|
|
|
|
|
assert_library_content_deletable(cur, profile_id, role, row)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/exercise-progression-graphs/{graph_id}/edges")
|
|
|
|
|
|
def list_progression_edges(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
from_exercise_id: Optional[int] = Query(default=None),
|
|
|
|
|
|
to_exercise_id: Optional[int] = Query(default=None),
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
_require_graph_read(cur, graph_id, profile_id, role)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
q = _EDGE_SELECT + " WHERE e.graph_id = %s"
|
|
|
|
|
|
params: List[Any] = [graph_id]
|
|
|
|
|
|
if from_exercise_id is not None:
|
|
|
|
|
|
q += " AND e.from_exercise_id = %s"
|
|
|
|
|
|
params.append(from_exercise_id)
|
|
|
|
|
|
if to_exercise_id is not None:
|
|
|
|
|
|
q += " AND e.to_exercise_id = %s"
|
|
|
|
|
|
params.append(to_exercise_id)
|
|
|
|
|
|
q += " ORDER BY e.id"
|
|
|
|
|
|
cur.execute(q, tuple(params))
|
|
|
|
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/exercise-progression-graphs/{graph_id}/edges", status_code=201)
|
|
|
|
|
|
def create_progression_edge(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
body: ProgressionEdgeCreate,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
_require_graph_write(cur, graph_id, profile_id, role)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
|
2026-06-14 06:44:12 +02:00
|
|
|
|
_assert_exercises_allowed_in_graph(
|
|
|
|
|
|
cur, graph_id, profile_id, role, body.from_exercise_id, body.to_exercise_id
|
|
|
|
|
|
)
|
2026-05-03 18:07:52 +02:00
|
|
|
|
fv = body.from_exercise_variant_id
|
|
|
|
|
|
tv = body.to_exercise_variant_id
|
|
|
|
|
|
_assert_variant_for_exercise(cur, body.from_exercise_id, fv)
|
|
|
|
|
|
_assert_variant_for_exercise(cur, body.to_exercise_id, tv)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
et = (body.edge_type or "next_exercise").strip() or "next_exercise"
|
|
|
|
|
|
notes = (body.notes or "").strip() or None
|
|
|
|
|
|
try:
|
2026-05-03 18:07:52 +02:00
|
|
|
|
row = _insert_edge_row(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
graph_id,
|
|
|
|
|
|
body.from_exercise_id,
|
|
|
|
|
|
fv,
|
|
|
|
|
|
body.to_exercise_id,
|
|
|
|
|
|
tv,
|
|
|
|
|
|
et,
|
|
|
|
|
|
notes,
|
2026-04-30 11:47:50 +02:00
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
except IntegrityError as e:
|
|
|
|
|
|
conn.rollback()
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=409,
|
2026-05-03 18:07:52 +02:00
|
|
|
|
detail="Kante existiert bereits oder Endpunkte unzulässig (gleiche Übung ohne zwei Varianten)",
|
2026-04-30 11:47:50 +02:00
|
|
|
|
) from e
|
|
|
|
|
|
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 18:07:52 +02:00
|
|
|
|
@router.post("/exercise-progression-graphs/{graph_id}/edges/sequence", status_code=201)
|
|
|
|
|
|
def create_progression_sequence(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
body: ProgressionSequenceCreate,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-05-03 18:07:52 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""Legt n−1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an."""
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-05-03 18:07:52 +02:00
|
|
|
|
steps = body.steps
|
|
|
|
|
|
n_seg = len(steps) - 1
|
|
|
|
|
|
seg_notes = body.segment_notes
|
|
|
|
|
|
|
|
|
|
|
|
created: List[dict] = []
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
_require_graph_write(cur, graph_id, profile_id, role)
|
2026-05-03 18:07:52 +02:00
|
|
|
|
|
|
|
|
|
|
ex_ids = [s.exercise_id for s in steps]
|
|
|
|
|
|
_assert_exercises_exist(cur, *ex_ids)
|
2026-06-14 06:44:12 +02:00
|
|
|
|
_assert_exercises_allowed_in_graph(cur, graph_id, profile_id, role, *ex_ids)
|
2026-05-03 18:07:52 +02:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
for i in range(n_seg):
|
|
|
|
|
|
a, b = steps[i], steps[i + 1]
|
|
|
|
|
|
_assert_variant_for_exercise(cur, a.exercise_id, a.variant_id)
|
|
|
|
|
|
_assert_variant_for_exercise(cur, b.exercise_id, b.variant_id)
|
|
|
|
|
|
note = None
|
|
|
|
|
|
if seg_notes is not None:
|
|
|
|
|
|
raw = seg_notes[i]
|
|
|
|
|
|
note = (raw or "").strip() or None if raw is not None else None
|
|
|
|
|
|
row = _insert_edge_row(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
graph_id,
|
|
|
|
|
|
a.exercise_id,
|
|
|
|
|
|
a.variant_id,
|
|
|
|
|
|
b.exercise_id,
|
|
|
|
|
|
b.variant_id,
|
|
|
|
|
|
"next_exercise",
|
|
|
|
|
|
note,
|
|
|
|
|
|
)
|
|
|
|
|
|
created.append(row)
|
2026-06-10 07:25:57 +02:00
|
|
|
|
if body.planning_roadmap is not None:
|
|
|
|
|
|
_persist_graph_planning_roadmap(cur, graph_id, body.planning_roadmap)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
|
|
|
|
|
|
(graph_id,),
|
|
|
|
|
|
)
|
2026-05-03 18:07:52 +02:00
|
|
|
|
conn.commit()
|
|
|
|
|
|
except IntegrityError as e:
|
|
|
|
|
|
conn.rollback()
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=409,
|
|
|
|
|
|
detail="Sequenz konnte nicht vollständig angelegt werden (Duplikat oder ungültige Endpunkte). Keine Teilmenge gespeichert.",
|
|
|
|
|
|
) from e
|
|
|
|
|
|
|
|
|
|
|
|
return {"created": created, "count": len(created)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
|
@router.put("/exercise-progression-graphs/{graph_id}/edges/{edge_id}")
|
|
|
|
|
|
def update_progression_edge(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
edge_id: int,
|
|
|
|
|
|
body: ProgressionEdgeUpdate,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
data = body.model_dump(exclude_unset=True)
|
|
|
|
|
|
if not data:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
|
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
_require_graph_write(cur, graph_id, profile_id, role)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s",
|
|
|
|
|
|
(edge_id, graph_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not cur.fetchone():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Kante nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
if "notes" in data:
|
|
|
|
|
|
n = data["notes"]
|
|
|
|
|
|
notes_val = (n or "").strip() or None if n is not None else None
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE exercise_progression_edges SET notes = %s WHERE id = %s AND graph_id = %s",
|
|
|
|
|
|
(notes_val, edge_id, graph_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
cur.execute(_EDGE_SELECT + " WHERE e.id = %s AND e.graph_id = %s", (edge_id, graph_id))
|
|
|
|
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/exercise-progression-graphs/{graph_id}/edges/{edge_id}")
|
|
|
|
|
|
def delete_progression_edge(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
edge_id: int,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-30 11:47:50 +02:00
|
|
|
|
):
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-04-30 11:47:50 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
_require_graph_write(cur, graph_id, profile_id, role)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
DELETE FROM exercise_progression_edges
|
|
|
|
|
|
WHERE id = %s AND graph_id = %s
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(edge_id, graph_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not cur.fetchone():
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Kante nicht gefunden")
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
return {"ok": True}
|
2026-05-03 18:07:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/exercise-progression-graphs/{graph_id}/edges/delete-batch")
|
|
|
|
|
|
def delete_progression_edges_batch(
|
|
|
|
|
|
graph_id: int,
|
|
|
|
|
|
body: EdgeIdsBatch,
|
2026-05-05 22:05:10 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-05-03 18:07:52 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt)."""
|
2026-05-05 22:05:10 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
2026-05-03 18:07:52 +02:00
|
|
|
|
ids = body.edge_ids
|
|
|
|
|
|
clean_ids = list(dict.fromkeys(ids))
|
|
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
_require_graph_write(cur, graph_id, profile_id, role)
|
2026-05-03 18:07:52 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"""
|
|
|
|
|
|
DELETE FROM exercise_progression_edges
|
|
|
|
|
|
WHERE graph_id = %s AND id IN ({",".join(["%s"] * len(clean_ids))})
|
|
|
|
|
|
""",
|
|
|
|
|
|
(graph_id, *clean_ids),
|
|
|
|
|
|
)
|
|
|
|
|
|
deleted = cur.rowcount
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
return {"ok": True, "deleted": deleted}
|