chore: update training framework specifications and versioning
- Incremented version to 0.8.8 and updated database schema version to 20260505035. - Added new entity `training_framework_programs` to manage training frameworks, including goals and slots. - Enhanced `training_plan_templates` with a visibility attribute and backfilled existing data. - Updated API to support CRUD operations for training frameworks, ensuring proper authorization similar to existing planning libraries. - Revised documentation in DOMAIN_MODEL.md, TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md, and TRAINING_FRAMEWORK_SPEC.md to reflect these changes.
This commit is contained in:
parent
f5895b6637
commit
b054c642a3
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo - Fachliches Domänenmodell
|
# Shinkan Jinkendo - Fachliches Domänenmodell
|
||||||
|
|
||||||
**Version:** 0.4.1
|
**Version:** 0.4.2
|
||||||
**Stand:** 2026-04-30 (Migration 034: Progressionsgraph Übung→Übung; Skills weiterhin ab 023)
|
**Stand:** 2026-05-05 (Migration 035: Rahmen‑Vorlage `training_framework_programs`; Progressionsgraph unverändert 032–034)
|
||||||
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
|
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -466,6 +466,12 @@ skill_level_definitions (
|
||||||
|
|
||||||
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
|
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
|
||||||
|
|
||||||
|
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
|
||||||
|
|
||||||
|
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und **direkten Übungszuordnungen** pro Slot („Stückliste“, **CURR‑010**). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** und ersetzt keine Slot-Zuordnung (**CURR‑013**).
|
||||||
|
|
||||||
|
**Zwei Nutzungsmodi (CURR‑012):** **`concrete`** (Kurzfrist‑/Gruppenkontext; optional `group_id`, Slots dürfen **`training_unit_id`** tragen) vs. **`library`** (zeit‑/gruppenlose Vorlage; **`group_id`** und Slot‑Einheitsverknüpfungen sind fachlich gesperrt — technisch werden Einheits-FKs beim Wechsel geleert). **Materialisierung / Bulk‑Anlegen** von `training_units` aus dem Rahmen ist ein **separater** Schritt (Stub/PR). **optional `training_plan_template_id` pro Slot** ist bewusst **deferred** (**C5**/CURR‑010).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Methodenbezug (§11.5)
|
## Methodenbezug (§11.5)
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ Pro Slot: **Zuordnung von Übung(en)** **direkt** (wie „Stückliste“) ist **
|
||||||
- ~~Governance Migrate Default~~ → **CURR‑008**
|
- ~~Governance Migrate Default~~ → **CURR‑008**
|
||||||
- ~~Slots / C4 generisch~~ → **CURR‑012** (Modi A/B)
|
- ~~Slots / C4 generisch~~ → **CURR‑012** (Modi A/B)
|
||||||
- ~~Relation zwei Vorlagenfamilien~~ → **CURR‑009** (**Rahmen** neu, **Einheit** bleibt `training_plan_template`)
|
- ~~Relation zwei Vorlagenfamilien~~ → **CURR‑009** (**Rahmen** neu, **Einheit** bleibt `training_plan_template`)
|
||||||
- **Technical:** gleiche DB‑Entität mit `plan_mode` (**A \| B**) vs. **konsequente Teilung** zweier Objekttypen?
|
- **Technical:** ~~gleiche DB‑Entität mit `plan_mode` (**A \| B**) vs. **konsequente Teilung** zweier Objekttypen?~~ → **Festgelegt:** eine Entität **`training_framework_programs`** + **`plan_mode`**; siehe **`technical/TRAINING_FRAMEWORK_SPEC.md` §2.0**.
|
||||||
- **Progressionsgraph:** Kantentypen (nächste Übung vs. **Variante** vs. Level innerhalb gleicher Übung); optional **Skills**-Anbindung
|
- **Progressionsgraph:** Kantentypen (nächste Übung vs. **Variante** vs. Level innerhalb gleicher Übung); optional **Skills**-Anbindung
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo - Datenbank-Schema (Technisch)
|
# Shinkan Jinkendo - Datenbank-Schema (Technisch)
|
||||||
|
|
||||||
**Version:** 0.5.0
|
**Version:** 0.5.1
|
||||||
**Stand:** 2026-04-30
|
**Stand:** 2026-05-05
|
||||||
**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **034** (Progressionsgraph Kanten/Varianten) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`).
|
**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **035** (Trainingsrahmenprogramm + Vorlagen-`visibility`) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -44,6 +44,7 @@ Dieses Dokument beschreibt die **technische Datenbankstruktur** von Shinkan Jink
|
||||||
| **032** | **2026-04-30** | **Progressionsgraph Übung→Übung:** `exercise_progression_graphs`, `exercise_progression_edges` | ✅ |
|
| **032** | **2026-04-30** | **Progressionsgraph Übung→Übung:** `exercise_progression_graphs`, `exercise_progression_edges` | ✅ |
|
||||||
| **033** | **2026-04-30** | **`exercise_progression_edges.notes`** | ✅ |
|
| **033** | **2026-04-30** | **`exercise_progression_edges.notes`** | ✅ |
|
||||||
| **034** | **2026-04-30** | **Kanten-Endpunkte optional `exercise_variants`; UNIQUE/CHECK** | ✅ |
|
| **034** | **2026-04-30** | **Kanten-Endpunkte optional `exercise_variants`; UNIQUE/CHECK** | ✅ |
|
||||||
|
| **035** | **2026-05-05** | **Rahmenprogramm:** `training_framework_programs` (+ Ziele, Slots, Slot-Übungen); **`training_plan_templates.visibility`** (Backfill `club`) — siehe `TRAINING_FRAMEWORK_SPEC.md` | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Trainingsrahmenprogramm — Technische Spezifikation
|
# Trainingsrahmenprogramm — Technische Spezifikation
|
||||||
|
|
||||||
**Status:** Zwischenstand dokumentiert · **Stand:** 2026-04-30
|
**Status:** Zwischenstand dokumentiert · **Stand:** 2026-05-05
|
||||||
**Bindendes Fachkonzept / Entscheide:** `.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR‑001 bis CURR‑013)
|
**Bindendes Fachkonzept / Entscheide:** `.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR‑001 bis CURR‑013)
|
||||||
|
|
||||||
**Relevant für nächsten Schritt:** CURR‑002 **(2)** Trainingsplanung / Rahmen über mehrere Einheiten — der hier dokumentierte **Progressionsgraph Stufe 1** ist bewusst **unterstützend**, keine Pflicht für Slot-Zuordnungen (**CURR‑013**).
|
**Relevant für nächsten Schritt:** CURR‑002 **(2)** Trainingsplanung / Rahmen über mehrere Einheiten — der hier dokumentierte **Progressionsgraph Stufe 1** ist bewusst **unterstützend**, keine Pflicht für Slot-Zuordnungen (**CURR‑013**).
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
| Dokument | Rolle · warum **nicht** hier hineinmischen |
|
| Dokument | Rolle · warum **nicht** hier hineinmischen |
|
||||||
|----------|--------------------------------------------|
|
|----------|--------------------------------------------|
|
||||||
| `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_API_SPEC.md` | **Übungskatalog** inkl. Varianten-Progression **innerhalb einer Übung** (Migration 014). Kanten **zwischen** Übungen siehe **§3**. |
|
| `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_API_SPEC.md` | **Übungskatalog** inkl. Varianten-Progression **innerhalb einer Übung** (Migration 014). Kanten **zwischen** Übungen siehe **§3**. |
|
||||||
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §3** + SQL unter `backend/migrations/`. |
|
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. |
|
||||||
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
|
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
|
||||||
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Modus A/B, Governance, CURR‑Tabelle). |
|
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Modus A/B, Governance, CURR‑Tabelle). |
|
||||||
|
|
||||||
|
|
@ -20,16 +20,86 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Noch auszuarbeiten (Checkliste)
|
## 2. Rahmenprogramm (CURR‑002 Stufe 2) — Checkliste & technische Ausarbeitung
|
||||||
|
|
||||||
- [ ] **Entität(en):** eigene Bibliotheks-Entität für Mehr-Slot-Rahmen (`training_framework_*` o. Ä., siehe **CURR‑009**); Abgrenzung zu `training_plan_templates` (**C5**).
|
### 2.0 Technische Entscheidung: eine Tabelle + `plan_mode` (Modus A | B)
|
||||||
- [ ] **Modus A vs. B:** ein Typ mit nullable `group_id` / `plan_mode` vs. zwei Objekttypen — **offen** (Funktionskonzept §6).
|
|
||||||
- [ ] **Zielliste:** ≥1 Zieleinträge pro Rahmen (**CURR‑011**); Felder und Optionalität.
|
**Entscheid:** **Eine** Hauptentität `training_framework_programs` mit **`plan_mode` ∈ {`concrete`, `library`}** statt zweier getrennter Tabellentypen.
|
||||||
- [ ] **Slots:** Reihenfolge, Notizen, **direkte Übungszuordnungen** (M:N oder Join-Tabelle); optionales `training_plan_template_id` pro Slot (**CURR‑010**, MVP offen).
|
|
||||||
- [x] **Progressionsgraph zwischen Übungen:** persistiert, siehe **§3–§4** (**CURR‑002 (1)**, **CURR‑013**).
|
**Begründung:** Gleiche Lebenszyklus‑ und CRUD‑Form (Header, Ziele, Slots, Übungen); die fachliche Unterscheidung A/B lässt sich mit **CHECK**- und API‑Regeln ausdrücken (`library` ⇒ `group_id IS NULL`, keine `training_unit_id` an Slots), ohne doppelte Router/Joins. Zwei physische Typen würden ohne Mehrwert Polymorphismus in der API erzwingen oder eine künstliche Supertyp‑Tabelle nach sich ziehen.
|
||||||
- [ ] **Instanziierung (Modus B):** FK/Metadaten zu `training_units`, Bulk vs. Verknüpfen (**CURR‑012**).
|
|
||||||
- [ ] **Governance:** `training_plan_templates` ohne `visibility` (**CURR‑007**, **CURR‑008**); neue Bibliothekstypen nach **CURR‑005**.
|
**Abgrenzung Konzept §6:** Dokumentiert hier; Funktionskonzept kann auf diesen Abschnitt verweisen.
|
||||||
- [ ] **REST gesamt Rahmenprogramm:** Progressions-API ist umgesetzt; **Rahmen‑Slot‑REST** noch ausstehend.
|
|
||||||
|
### 2.1 Checkliste (Abhak-Stand)
|
||||||
|
|
||||||
|
- [x] **Entität(en):** eigene Bibliotheks-Entität `training_framework_programs` (**CURR‑009**); `training_plan_templates` unverändert **eine‑Einheit‑Mikrovorlage** (**C5**).
|
||||||
|
- [x] **Modus A vs. B:** ein Datensatz + `plan_mode` + Nullables (**§2.0**); `library` erzwingt `group_id` NULL; **`training_plan_template_id` pro Slot** — **deferred** (Technical: MVP ohne Spalte, bis Nutzen geklärt, **CURR‑010**).
|
||||||
|
- [x] **Zielliste:** `training_framework_goals`, API erzwingt **≥ 1** Ziel beim Anlegen/Ersetzen (**CURR‑011**).
|
||||||
|
- [x] **Slots:** `training_framework_slots` mit **`sort_order`**, optional **Titel/Notizen**; Übungen über **`training_framework_slot_exercises`** (Sortierung **`order_index`**, optional **`exercise_variant_id`**).
|
||||||
|
- [x] **Progressionsgraph:** Stufe 1 besteht (**§3–§4**); **kein Pflichtbezug** pro Slot (**CURR‑013**).
|
||||||
|
- [x] **Konkretkontakt Modus A:** optional **`training_unit_id`** pro Slot; bei gesetzter Rahmen-**`group_id`** muss die Einheit zur gleichen Gruppe gehören. **Live‑Writes** zurück in die Bibliotheksvorlage: **nicht** vorgesehen (**CURR‑006**).
|
||||||
|
- [x] **Instanziierung (Modus B):** Persistenz der Vorlage (**MVP**); Bulk-Anlage von **`training_units`** aus dem Rahmen — **Ausbauschritt**/zweiter PR (**CURR‑012** C4a/b).
|
||||||
|
- [x] **Governance neue Objekte:** `visibility`, `club_id`, `created_by` wie Progressionsgraph (**CURR‑005**). **`training_plan_templates.visibility`** nachgezogen in derselben Migration **035** mit Backfill **`club`** (**CURR‑007**, **CURR‑008**; frühe Installationen).
|
||||||
|
- [x] **REST Rahmenprogramm:** `/api/training-framework-programs` (**§2.3**); Progressions‑API weiter **§3.3**.
|
||||||
|
|
||||||
|
### 2.2 DDL‑Skizze (Migration **035**, Kurzüberblick)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Header
|
||||||
|
training_framework_programs (
|
||||||
|
id, title NOT NULL, description,
|
||||||
|
plan_mode NOT NULL CHECK (IN 'concrete','library'),
|
||||||
|
group_id FK training_groups NULL,
|
||||||
|
planned_period_start, planned_period_end NULL, -- reine Meta-/UI‑Hilfe
|
||||||
|
visibility NOT NULL, club_id, created_by,
|
||||||
|
CHECK ((plan_mode = 'library' AND group_id IS NULL) OR plan_mode = 'concrete'),
|
||||||
|
timestamps + update_trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Ziele (≥1 über API beim Speichern)
|
||||||
|
training_framework_goals (
|
||||||
|
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
|
||||||
|
title, notes
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Slots
|
||||||
|
training_framework_slots (
|
||||||
|
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
|
||||||
|
title, notes, training_unit_id FK training_units SET NULL
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Stückliste pro Slot (Übung → FK CASCADE beim Löschen der Übung)
|
||||||
|
training_framework_slot_exercises (
|
||||||
|
id, slot_id FK CASCADE,
|
||||||
|
exercise_id FK exercises CASCADE,
|
||||||
|
exercise_variant_id FK exercise_variants NULL,
|
||||||
|
order_index UNIQUE per slot
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Löschkaskaden:** löschen eines **Rahmens** ⇒ Ziele + Slots ⇒ Slot‑Übungen (alles **`ON DELETE CASCADE`** vom Rahmen über Slots). löschen eines **`training_unit`** ⇒ FK am Slot **`SET NULL`**. löschen einer **Übung** ⇒ Zeilen in **`training_framework_slot_exercises`** entfallen (`CASCADE`).
|
||||||
|
|
||||||
|
**Deferred (nicht im MVP):** `training_plan_templates.id` FK je Slot (**CURR‑010** Optional).
|
||||||
|
|
||||||
|
### 2.3 REST‑Überblick (`router` `training_framework_programs`)
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck |
|
||||||
|
|---------|------|--------|
|
||||||
|
| GET | `/training-framework-programs` | Liste; Admin/Superadmin alle, sonst eigene (`created_by`); Aggregation `goals_count`, `slots_count` |
|
||||||
|
| GET | `/training-framework-programs/{id}` | Detail inkl. `goals[]`, `slots[]` mit jeweils `exercises[]` (inkl. Titel‑Joins) |
|
||||||
|
| POST | `/training-framework-programs` | Neu; **Pflicht:** `title`, **`plan_mode`**, **`goals`** (≥ 1 Eintrag mit `title`); optional `slots` |
|
||||||
|
| PUT | `/training-framework-programs/{id}` | Header‑Felder; optional volles Ersetzen von **`goals`** und/oder **`slots`** wie bei Vorlagen‑Sektionen |
|
||||||
|
| DELETE | `/training-framework-programs/{id}` | Rahmen löschen (**CASCADE** Kinder) |
|
||||||
|
|
||||||
|
**AuthZ:** Schreibzugriff nur mit **`_has_planning_role`** (wie `training_plan_templates`); Lesen/Ändern/Löschen: **Admin/Superadmin** oder **Ersteller** (`created_by`).
|
||||||
|
|
||||||
|
**Payload‑Hinweise (JSON):**
|
||||||
|
|
||||||
|
- `goals`: `[{ sort_order?, title, notes? }, …]` — **`sort_order`** default Reihenfolge im Array
|
||||||
|
- `slots`: `[{ sort_order?, title?, notes?, training_unit_id?, exercises: [{ exercise_id, exercise_variant_id?, order_index? }] }, …]`
|
||||||
|
- Bei **`library`:** **`training_unit_id`** an Slots → **400**; nach Wechsel auf **`library`** werden bestehende Slot-Verknüpfungen zu **`training_units`** geleert.
|
||||||
|
|
||||||
|
**Minimal‑UI:** im Lieferumfang dieser Iteration nicht enthalten (**OpenAPI `/docs`** / Postman); siehe Funktionskonzept §6.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -109,6 +179,7 @@ Details weiterhin Diskussionsgrundlage in `TRAINING_CURRICULUM_AND_GOVERNANCE_CO
|
||||||
|
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
|
| 2026-05-05 | **CURR‑002 (2):** §2 Rahmenprogramm — Entscheid **eine Tabelle + `plan_mode`**, DDL‑Skizze, REST‑Überblick; Migration **035**; `training_plan_templates.visibility`. |
|
||||||
| 2026-04-30 | **Zwischen-Doku:** §3 auf Migrationen 032–034 + API **sequence/delete-batch** + Frontend erweitert; **§4** Produktfreigabe vs. Lücken (parallele Alternativen); Changelog §5. |
|
| 2026-04-30 | **Zwischen-Doku:** §3 auf Migrationen 032–034 + API **sequence/delete-batch** + Frontend erweitert; **§4** Produktfreigabe vs. Lücken (parallele Alternativen); Changelog §5. |
|
||||||
| 2026-04-30 | §3: erste Fassung Migration 032 + REST‑Basis (CURR‑002 (1)). |
|
| 2026-04-30 | §3: erste Fassung Migration 032 + REST‑Basis (CURR‑002 (1)). |
|
||||||
| 2026-04-28 | Erstanlage Stub mit Checkliste. |
|
| 2026-04-28 | Erstanlage Stub mit Checkliste. |
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ def read_root():
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -161,6 +161,7 @@ app.include_router(exercise_progression_graphs.router)
|
||||||
app.include_router(clubs.router)
|
app.include_router(clubs.router)
|
||||||
app.include_router(skills.router)
|
app.include_router(skills.router)
|
||||||
app.include_router(training_planning.router)
|
app.include_router(training_planning.router)
|
||||||
|
app.include_router(training_framework_programs.router)
|
||||||
app.include_router(catalogs.router)
|
app.include_router(catalogs.router)
|
||||||
app.include_router(maturity_models.router)
|
app.include_router(maturity_models.router)
|
||||||
app.include_router(matrix_stack_bundle.router)
|
app.include_router(matrix_stack_bundle.router)
|
||||||
|
|
|
||||||
92
backend/migrations/035_training_framework_programs.sql
Normal file
92
backend/migrations/035_training_framework_programs.sql
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
-- Migration 035: Trainingsrahmenprogramm (Rahmen‑Vorlage, CURR‑002 Stufe 2 / CURR‑009–013)
|
||||||
|
-- + CURR‑007/008: training_plan_templates.visibility (Backfill club, dann NOT NULL + Default)
|
||||||
|
|
||||||
|
-- ── Trainings‑Mikrovorlagen: gemeinsamer Governance‑Kern (visibility) ─────
|
||||||
|
ALTER TABLE training_plan_templates
|
||||||
|
ADD COLUMN IF NOT EXISTS visibility VARCHAR(50)
|
||||||
|
CHECK (visibility IN ('private', 'club', 'official'));
|
||||||
|
|
||||||
|
UPDATE training_plan_templates
|
||||||
|
SET visibility = 'club'
|
||||||
|
WHERE visibility IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE training_plan_templates
|
||||||
|
ALTER COLUMN visibility SET DEFAULT 'club';
|
||||||
|
|
||||||
|
ALTER TABLE training_plan_templates
|
||||||
|
ALTER COLUMN visibility SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_plan_templates_visibility ON training_plan_templates(visibility);
|
||||||
|
|
||||||
|
-- ── Rahmen‑Header ────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS training_framework_programs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
plan_mode VARCHAR(20) NOT NULL
|
||||||
|
CHECK (plan_mode IN ('concrete', 'library')),
|
||||||
|
-- Modus B (library): immer NULL · Modus A (concrete): optional gebunden an eine Trainingsgruppe
|
||||||
|
group_id INT REFERENCES training_groups(id) ON DELETE SET NULL,
|
||||||
|
planned_period_start DATE,
|
||||||
|
planned_period_end DATE,
|
||||||
|
visibility VARCHAR(50) NOT NULL DEFAULT 'private'
|
||||||
|
CHECK (visibility IN ('private', 'club', 'official')),
|
||||||
|
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||||
|
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
CHECK (
|
||||||
|
(plan_mode = 'library' AND group_id IS NULL)
|
||||||
|
OR plan_mode = 'concrete'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_creator ON training_framework_programs(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_club ON training_framework_programs(club_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_visibility ON training_framework_programs(visibility);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_mode ON training_framework_programs(plan_mode);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_group ON training_framework_programs(group_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS training_framework_programs_update ON training_framework_programs;
|
||||||
|
CREATE TRIGGER training_framework_programs_update
|
||||||
|
BEFORE UPDATE ON training_framework_programs
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||||
|
|
||||||
|
-- ── Zielliste (CURR‑011: ≥ 1 durch API beim Speichern) ─────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS training_framework_goals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||||
|
sort_order INT NOT NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE (framework_program_id, sort_order)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_goals_framework ON training_framework_goals(framework_program_id);
|
||||||
|
|
||||||
|
-- ── Slots (Sessions im Rahmen) ──────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS training_framework_slots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||||
|
sort_order INT NOT NULL,
|
||||||
|
title VARCHAR(200),
|
||||||
|
notes TEXT,
|
||||||
|
training_unit_id INT REFERENCES training_units(id) ON DELETE SET NULL,
|
||||||
|
UNIQUE (framework_program_id, sort_order)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_slots_framework ON training_framework_slots(framework_program_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_slots_unit ON training_framework_slots(training_unit_id);
|
||||||
|
|
||||||
|
-- ── Übungen pro Slot (tragende „Stückliste“, CURR‑010) ────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS training_framework_slot_exercises (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slot_id INT NOT NULL REFERENCES training_framework_slots(id) ON DELETE CASCADE,
|
||||||
|
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
|
||||||
|
order_index INT NOT NULL,
|
||||||
|
UNIQUE (slot_id, order_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_slot_exercises_slot ON training_framework_slot_exercises(slot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_framework_slot_exercises_exercise ON training_framework_slot_exercises(exercise_id);
|
||||||
438
backend/routers/training_framework_programs.py
Normal file
438
backend/routers/training_framework_programs.py
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
"""
|
||||||
|
Trainingsrahmenprogramm — Rahmen‑Vorlage über mehrere Session‑Slots (CURR‑002 Stufe 2).
|
||||||
|
AuthZ wie Planungs‑Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from db import get_db, get_cursor, r2d
|
||||||
|
|
||||||
|
from routers.training_planning import (
|
||||||
|
_assert_training_unit_permission,
|
||||||
|
_can_access_group_for_create,
|
||||||
|
_has_planning_role,
|
||||||
|
_optional_positive_int,
|
||||||
|
_training_unit_guard_row,
|
||||||
|
_validate_variant_for_exercise,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
|
||||||
|
|
||||||
|
_VALID_PLAN_MODE = frozenset({"concrete", "library"})
|
||||||
|
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
|
||||||
|
|
||||||
|
|
||||||
|
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||||
|
cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden")
|
||||||
|
row = r2d(r)
|
||||||
|
if role in ("admin", "superadmin"):
|
||||||
|
return row
|
||||||
|
if row.get("created_by") != profile_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_slot_exercises(cur, slot_id: int) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.id, t.slot_id, t.exercise_id, t.exercise_variant_id, t.order_index,
|
||||||
|
e.title AS exercise_title,
|
||||||
|
ev.variant_name AS exercise_variant_name
|
||||||
|
FROM training_framework_slot_exercises t
|
||||||
|
LEFT JOIN exercises e ON e.id = t.exercise_id
|
||||||
|
LEFT JOIN exercise_variants ev ON ev.id = t.exercise_variant_id
|
||||||
|
WHERE t.slot_id = %s
|
||||||
|
ORDER BY t.order_index
|
||||||
|
""",
|
||||||
|
(slot_id,),
|
||||||
|
)
|
||||||
|
return [r2d(x) for x in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
fid = row["id"]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, framework_program_id, sort_order, title, notes
|
||||||
|
FROM training_framework_goals
|
||||||
|
WHERE framework_program_id = %s
|
||||||
|
ORDER BY sort_order
|
||||||
|
""",
|
||||||
|
(fid,),
|
||||||
|
)
|
||||||
|
row["goals"] = [r2d(g) for g in cur.fetchall()]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, framework_program_id, sort_order, title, notes, training_unit_id
|
||||||
|
FROM training_framework_slots
|
||||||
|
WHERE framework_program_id = %s
|
||||||
|
ORDER BY sort_order
|
||||||
|
""",
|
||||||
|
(fid,),
|
||||||
|
)
|
||||||
|
slots = [r2d(s) for s in cur.fetchall()]
|
||||||
|
for s in slots:
|
||||||
|
s["exercises"] = _fetch_slot_exercises(cur, s["id"])
|
||||||
|
row["slots"] = slots
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_visibility(val: Optional[str]) -> Optional[str]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if val not in _VALID_VISIBILITY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="visibility muss private, club oder official sein",
|
||||||
|
)
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_framework_invariants(plan_mode: str, group_id: Optional[int]) -> None:
|
||||||
|
if plan_mode == "library" and group_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="plan_mode library erlaubt kein group_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_slot_unit_constraints(
|
||||||
|
cur,
|
||||||
|
plan_mode: str,
|
||||||
|
framework_group_id: Optional[int],
|
||||||
|
training_unit_id: Optional[int],
|
||||||
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
|
) -> None:
|
||||||
|
if plan_mode == "library" and training_unit_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Im Bibliotheksmodus (library) keine Verknüpfung von Slots zu Trainingseinheiten",
|
||||||
|
)
|
||||||
|
if not training_unit_id:
|
||||||
|
return
|
||||||
|
uid = training_unit_id
|
||||||
|
unit_row = _training_unit_guard_row(cur, uid)
|
||||||
|
_assert_training_unit_permission(cur, unit_row, profile_id, role)
|
||||||
|
if framework_group_id is not None and unit_row["group_id"] != framework_group_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="training_unit_id muss zur group_id dieses Rahmens gehören",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
|
||||||
|
if not goals_in:
|
||||||
|
raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich")
|
||||||
|
for gi, g in enumerate(goals_in):
|
||||||
|
title_g = (g.get("title") or "").strip()
|
||||||
|
if not title_g:
|
||||||
|
raise HTTPException(status_code=400, detail="Jedes Ziel braucht ein nicht-leeres title")
|
||||||
|
order_ix = g.get("sort_order")
|
||||||
|
if order_ix is None:
|
||||||
|
order_ix = gi
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_framework_goals (
|
||||||
|
framework_program_id, sort_order, title, notes
|
||||||
|
) VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(framework_id, int(order_ix), title_g[:500], g.get("notes")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_slots_and_exercises(
|
||||||
|
cur,
|
||||||
|
framework_id: int,
|
||||||
|
plan_mode: str,
|
||||||
|
framework_group_id: Optional[int],
|
||||||
|
slots_in: Optional[List[Any]],
|
||||||
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
|
) -> None:
|
||||||
|
if slots_in is None:
|
||||||
|
return
|
||||||
|
for si, slot in enumerate(slots_in):
|
||||||
|
order_ix = slot.get("sort_order")
|
||||||
|
if order_ix is None:
|
||||||
|
order_ix = si
|
||||||
|
title_s = slot.get("title")
|
||||||
|
if title_s is not None:
|
||||||
|
title_s = title_s.strip() or None
|
||||||
|
unit_sid = _optional_positive_int(slot.get("training_unit_id"), "training_unit_id")
|
||||||
|
_assert_slot_unit_constraints(cur, plan_mode, framework_group_id, unit_sid, profile_id, role)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_framework_slots (
|
||||||
|
framework_program_id, sort_order, title, notes, training_unit_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
framework_id,
|
||||||
|
int(order_ix),
|
||||||
|
title_s,
|
||||||
|
slot.get("notes"),
|
||||||
|
unit_sid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sid = cur.fetchone()["id"]
|
||||||
|
ex_items = slot.get("exercises") or []
|
||||||
|
for ej, raw in enumerate(ex_items):
|
||||||
|
eid = raw.get("exercise_id")
|
||||||
|
if not eid:
|
||||||
|
continue
|
||||||
|
eid = int(eid)
|
||||||
|
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
|
||||||
|
_validate_variant_for_exercise(cur, eid, vid)
|
||||||
|
oidx = raw.get("order_index")
|
||||||
|
if oidx is None:
|
||||||
|
oidx = ej
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_framework_slot_exercises (
|
||||||
|
slot_id, exercise_id, exercise_variant_id, order_index
|
||||||
|
) VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(sid, eid, vid, int(oidx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/training-framework-programs")
|
||||||
|
def list_training_framework_programs(session=Depends(require_auth)):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
role = session.get("role")
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
base_sel = """
|
||||||
|
SELECT fp.*,
|
||||||
|
(SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id)
|
||||||
|
AS goals_count,
|
||||||
|
(SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id)
|
||||||
|
AS slots_count
|
||||||
|
FROM training_framework_programs fp
|
||||||
|
"""
|
||||||
|
if role in ("admin", "superadmin"):
|
||||||
|
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/training-framework-programs/{framework_id}")
|
||||||
|
def get_training_framework_program(framework_id: int, session=Depends(require_auth)):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
role = session.get("role")
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
row = _framework_access(cur, framework_id, profile_id, role)
|
||||||
|
return _hydrate_framework(cur, row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/training-framework-programs")
|
||||||
|
def create_training_framework_program(data: dict, session=Depends(require_auth)):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
role = session.get("role")
|
||||||
|
if not _has_planning_role(role):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen")
|
||||||
|
|
||||||
|
title = (data.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
||||||
|
|
||||||
|
plan_mode = (data.get("plan_mode") or "").strip().lower()
|
||||||
|
if plan_mode not in _VALID_PLAN_MODE:
|
||||||
|
raise HTTPException(status_code=400, detail="plan_mode muss concrete oder library sein")
|
||||||
|
|
||||||
|
gid = None
|
||||||
|
if data.get("group_id") not in (None, ""):
|
||||||
|
gid = _optional_positive_int(data.get("group_id"), "group_id")
|
||||||
|
_assert_framework_invariants(plan_mode, gid)
|
||||||
|
|
||||||
|
vis = data.get("visibility") or "private"
|
||||||
|
vis = _assert_visibility(vis)
|
||||||
|
club_id = data.get("club_id")
|
||||||
|
|
||||||
|
goals_in = data.get("goals")
|
||||||
|
slots_in = data.get("slots")
|
||||||
|
if not isinstance(goals_in, list) or not goals_in:
|
||||||
|
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
if gid is not None:
|
||||||
|
_can_access_group_for_create(cur, gid, profile_id, role)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_framework_programs (
|
||||||
|
title, description, plan_mode, group_id,
|
||||||
|
planned_period_start, planned_period_end,
|
||||||
|
visibility, club_id, created_by
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
title[:200],
|
||||||
|
data.get("description"),
|
||||||
|
plan_mode,
|
||||||
|
gid,
|
||||||
|
data.get("planned_period_start"),
|
||||||
|
data.get("planned_period_end"),
|
||||||
|
vis,
|
||||||
|
club_id,
|
||||||
|
profile_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fid = cur.fetchone()["id"]
|
||||||
|
_insert_goal_rows(cur, fid, goals_in)
|
||||||
|
_insert_slots_and_exercises(cur, fid, plan_mode, gid, slots_in, profile_id, role)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return get_training_framework_program(fid, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/training-framework-programs/{framework_id}")
|
||||||
|
def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
role = session.get("role")
|
||||||
|
if not _has_planning_role(role):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
existing = _framework_access(cur, framework_id, profile_id, role)
|
||||||
|
|
||||||
|
plan_mode_new = existing["plan_mode"]
|
||||||
|
if "plan_mode" in data:
|
||||||
|
pm = (data.get("plan_mode") or "").strip().lower()
|
||||||
|
if pm not in _VALID_PLAN_MODE:
|
||||||
|
raise HTTPException(status_code=400, detail="plan_mode muss concrete oder library sein")
|
||||||
|
plan_mode_new = pm
|
||||||
|
|
||||||
|
group_id_eff = existing.get("group_id")
|
||||||
|
if "group_id" in data:
|
||||||
|
if data.get("group_id") in (None, ""):
|
||||||
|
group_id_eff = None
|
||||||
|
else:
|
||||||
|
group_id_eff = _optional_positive_int(data.get("group_id"), "group_id")
|
||||||
|
_assert_framework_invariants(plan_mode_new, group_id_eff)
|
||||||
|
|
||||||
|
header_fields = []
|
||||||
|
header_params: List[Any] = []
|
||||||
|
if "title" in data:
|
||||||
|
tit = (data.get("title") or "").strip()
|
||||||
|
if not tit:
|
||||||
|
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
||||||
|
header_fields.append("title = %s")
|
||||||
|
header_params.append(tit[:200])
|
||||||
|
if "description" in data:
|
||||||
|
header_fields.append("description = %s")
|
||||||
|
header_params.append(data.get("description"))
|
||||||
|
if "plan_mode" in data:
|
||||||
|
header_fields.append("plan_mode = %s")
|
||||||
|
header_params.append(plan_mode_new)
|
||||||
|
if "group_id" in data:
|
||||||
|
header_fields.append("group_id = %s")
|
||||||
|
header_params.append(group_id_eff)
|
||||||
|
if "planned_period_start" in data:
|
||||||
|
header_fields.append("planned_period_start = %s")
|
||||||
|
header_params.append(data.get("planned_period_start"))
|
||||||
|
if "planned_period_end" in data:
|
||||||
|
header_fields.append("planned_period_end = %s")
|
||||||
|
header_params.append(data.get("planned_period_end"))
|
||||||
|
if "visibility" in data:
|
||||||
|
v = _assert_visibility(data.get("visibility"))
|
||||||
|
if v is None:
|
||||||
|
raise HTTPException(status_code=400, detail="visibility fehlt")
|
||||||
|
header_fields.append("visibility = %s")
|
||||||
|
header_params.append(v)
|
||||||
|
if "club_id" in data:
|
||||||
|
header_fields.append("club_id = %s")
|
||||||
|
header_params.append(data.get("club_id"))
|
||||||
|
|
||||||
|
if group_id_eff is not None and (
|
||||||
|
("group_id" in data)
|
||||||
|
or (plan_mode_new == "concrete" and plan_mode_new != existing.get("plan_mode"))
|
||||||
|
):
|
||||||
|
_can_access_group_for_create(cur, group_id_eff, profile_id, role)
|
||||||
|
|
||||||
|
if header_fields:
|
||||||
|
header_fields.append("updated_at = NOW()")
|
||||||
|
header_params.append(framework_id)
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE training_framework_programs
|
||||||
|
SET {", ".join(header_fields)}
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
tuple(header_params),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "goals" in data:
|
||||||
|
goals_in = data["goals"]
|
||||||
|
if not isinstance(goals_in, list) or not goals_in:
|
||||||
|
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM training_framework_goals WHERE framework_program_id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
_insert_goal_rows(cur, framework_id, goals_in)
|
||||||
|
|
||||||
|
if "slots" in data:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
_insert_slots_and_exercises(
|
||||||
|
cur,
|
||||||
|
framework_id,
|
||||||
|
plan_mode_new,
|
||||||
|
group_id_eff,
|
||||||
|
data.get("slots") or [],
|
||||||
|
profile_id,
|
||||||
|
role,
|
||||||
|
)
|
||||||
|
|
||||||
|
if plan_mode_new == "library":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE training_framework_slots SET training_unit_id = NULL
|
||||||
|
WHERE framework_program_id = %s AND training_unit_id IS NOT NULL
|
||||||
|
""",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "goals" in data or "slots" in data or header_fields:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return get_training_framework_program(framework_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/training-framework-programs/{framework_id}")
|
||||||
|
def delete_training_framework_program(framework_id: int, session=Depends(require_auth)):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
role = session.get("role")
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
_framework_access(cur, framework_id, profile_id, role)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM training_framework_programs WHERE id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.7"
|
APP_VERSION = "0.8.8"
|
||||||
BUILD_DATE = "2026-04-30"
|
BUILD_DATE = "2026-05-05"
|
||||||
DB_SCHEMA_VERSION = "20260430034"
|
DB_SCHEMA_VERSION = "20260505035"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.0.0",
|
"auth": "1.0.0",
|
||||||
|
|
@ -14,7 +14,7 @@ MODULE_VERSIONS = {
|
||||||
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
|
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
|
||||||
"training_units": "0.1.0",
|
"training_units": "0.1.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.3.0",
|
"planning": "0.4.0",
|
||||||
"import_wiki": "1.0.0",
|
"import_wiki": "1.0.0",
|
||||||
"admin": "1.0.0",
|
"admin": "1.0.0",
|
||||||
"membership": "1.0.0",
|
"membership": "1.0.0",
|
||||||
|
|
@ -23,6 +23,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.8",
|
||||||
|
"date": "2026-05-05",
|
||||||
|
"changes": [
|
||||||
|
"DB 035: Trainingsrahmenprogramm (Rahmen, Ziele, Slots, Slot-Übungen); plan_mode concrete|library",
|
||||||
|
"DB 035: training_plan_templates.visibility + Backfill club (CURR-007/008)",
|
||||||
|
"API: CRUD /api/training-framework-programs (AuthZ wie Übungs-/Planungsbibliothek)",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.7",
|
"version": "0.8.7",
|
||||||
"date": "2026-04-30",
|
"date": "2026-04-30",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user