Merge pull request 'Ki und Admin Feature' (#53) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m24s
All checks were successful
Deploy Production / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m24s
Reviewed-on: #53
This commit is contained in:
commit
50c9beb4b3
|
|
@ -38,12 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||||
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
||||||
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
|
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
|
||||||
|
| admin_user_content | `/api/admin/user-content/*` (Meta, Nutzer-Summary, Items, PATCH, DELETE) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; Moderation nutzerangelegter Inhalte inkl. privat; kein TenantContext |
|
||||||
|
|
||||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||||
|
|
||||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||||
|
|
||||||
Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review).
|
Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -203,6 +203,7 @@ app.include_router(clubs.router)
|
||||||
app.include_router(club_memberships.router)
|
app.include_router(club_memberships.router)
|
||||||
app.include_router(club_join_requests.router)
|
app.include_router(club_join_requests.router)
|
||||||
app.include_router(admin_users.router)
|
app.include_router(admin_users.router)
|
||||||
|
app.include_router(admin_user_content.router)
|
||||||
app.include_router(platform_media_storage.router)
|
app.include_router(platform_media_storage.router)
|
||||||
app.include_router(media_assets.router)
|
app.include_router(media_assets.router)
|
||||||
app.include_router(media_assets.admin_rights_router)
|
app.include_router(media_assets.admin_rights_router)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
-- Migration 077: Planungs-Pfad-QA — strukturierte Neuanlage-Vorschläge (Phase E3)
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
output_schema = '{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"},"ordered_step_indices":{"type":"array"},"suggested_new_exercises":{"type":"array"}}}'::jsonb
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
"""
|
"""
|
||||||
Planungs-KI Phase E2: KI-Neuanlage-Vorschläge für unüberbrückbare Pfad-Lücken.
|
Planungs-KI Phase E2/E3: KI-Neuanlage für Pfad-Lücken + strukturierte Angebote für die UI.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Mapping, Optional
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
from ai_prompt_context import ExerciseFormAiPromptContext
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||||
from exercise_ai import strip_html_to_plain
|
from exercise_ai import strip_html_to_plain
|
||||||
|
|
||||||
|
from planning_exercise_path_qa import find_step_pair_index
|
||||||
from planning_exercise_semantics import PlanningSemanticBrief
|
from planning_exercise_semantics import PlanningSemanticBrief
|
||||||
|
|
||||||
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
||||||
|
|
@ -23,19 +24,27 @@ def _build_gap_ai_context(
|
||||||
step_a: Mapping[str, Any],
|
step_a: Mapping[str, Any],
|
||||||
step_b: Mapping[str, Any],
|
step_b: Mapping[str, Any],
|
||||||
gap: Mapping[str, Any],
|
gap: Mapping[str, Any],
|
||||||
|
title_hint: Optional[str] = None,
|
||||||
|
sketch_hint: Optional[str] = None,
|
||||||
) -> ExerciseFormAiPromptContext:
|
) -> ExerciseFormAiPromptContext:
|
||||||
topic = (brief.primary_topic or "Technik").strip()
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
phase = gap.get("expected_phase") or "vertiefung"
|
phase = gap.get("expected_phase") or "vertiefung"
|
||||||
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
|
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
|
||||||
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
|
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
|
||||||
|
|
||||||
title = f"Brücke {topic} ({phase})"
|
title = (title_hint or f"Brücke {topic} ({phase})").strip()[:280]
|
||||||
goal = (
|
sketch = (sketch_hint or "").strip()
|
||||||
f"Planungsziel: {goal_query}\n\n"
|
goal_parts = [
|
||||||
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.\n"
|
f"Planungsziel: {goal_query}",
|
||||||
f"Phase: {phase}. Thema: {topic}. "
|
"",
|
||||||
f"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor."
|
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.",
|
||||||
)
|
f"Phase: {phase}. Thema: {topic}.",
|
||||||
|
"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor.",
|
||||||
|
]
|
||||||
|
if sketch:
|
||||||
|
goal_parts.extend(["", f"Hinweis: {sketch}"])
|
||||||
|
goal = "\n".join(goal_parts)
|
||||||
|
|
||||||
focus_hint = topic if brief.topic_type == "technique" else None
|
focus_hint = topic if brief.topic_type == "technique" else None
|
||||||
if brief.must_phrases:
|
if brief.must_phrases:
|
||||||
focus_hint = ", ".join(brief.must_phrases[:2])
|
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||||
|
|
@ -81,8 +90,8 @@ def ai_proposal_to_path_step(
|
||||||
"is_ai_proposal": True,
|
"is_ai_proposal": True,
|
||||||
"ai_suggestion": dict(ai_payload),
|
"ai_suggestion": dict(ai_payload),
|
||||||
"bridge_for_gap": {
|
"bridge_for_gap": {
|
||||||
"from_exercise_id": int(step_a["exercise_id"]),
|
"from_exercise_id": step_a.get("exercise_id"),
|
||||||
"to_exercise_id": int(step_b["exercise_id"]),
|
"to_exercise_id": step_b.get("exercise_id"),
|
||||||
"gap_score": gap.get("gap_score"),
|
"gap_score": gap.get("gap_score"),
|
||||||
"expected_phase": gap.get("expected_phase"),
|
"expected_phase": gap.get("expected_phase"),
|
||||||
},
|
},
|
||||||
|
|
@ -97,6 +106,8 @@ def try_suggest_ai_bridge_step(
|
||||||
step_a: Mapping[str, Any],
|
step_a: Mapping[str, Any],
|
||||||
step_b: Mapping[str, Any],
|
step_b: Mapping[str, Any],
|
||||||
gap: Mapping[str, Any],
|
gap: Mapping[str, Any],
|
||||||
|
title_hint: Optional[str] = None,
|
||||||
|
sketch_hint: Optional[str] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
|
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
|
||||||
ctx = _build_gap_ai_context(
|
ctx = _build_gap_ai_context(
|
||||||
|
|
@ -105,6 +116,8 @@ def try_suggest_ai_bridge_step(
|
||||||
step_a=step_a,
|
step_a=step_a,
|
||||||
step_b=step_b,
|
step_b=step_b,
|
||||||
gap=gap,
|
gap=gap,
|
||||||
|
title_hint=title_hint,
|
||||||
|
sketch_hint=sketch_hint,
|
||||||
)
|
)
|
||||||
g_plain = strip_html_to_plain(ctx.goal)
|
g_plain = strip_html_to_plain(ctx.goal)
|
||||||
if not g_plain.strip() and not (ctx.title or "").strip():
|
if not g_plain.strip() and not (ctx.title or "").strip():
|
||||||
|
|
@ -132,6 +145,217 @@ def try_suggest_ai_bridge_step(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_sketch(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_a: Optional[Mapping[str, Any]],
|
||||||
|
step_b: Optional[Mapping[str, Any]],
|
||||||
|
phase: str,
|
||||||
|
rationale: str = "",
|
||||||
|
) -> str:
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
from_t = (step_a or {}).get("title") or "vorherigem Schritt"
|
||||||
|
to_t = (step_b or {}).get("title") or "nächstem Schritt"
|
||||||
|
parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
f"Zwischenschritt für {topic} ({phase}) zwischen „{from_t}“ und „{to_t}“.",
|
||||||
|
]
|
||||||
|
if rationale:
|
||||||
|
parts.append(rationale)
|
||||||
|
return " ".join(parts)[:1200]
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
|
||||||
|
return (
|
||||||
|
spec.get("source"),
|
||||||
|
int(spec.get("insert_after_index") or 0),
|
||||||
|
str(spec.get("title_hint") or "")[:48],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_gap_fill_specs(
|
||||||
|
*,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
unfilled_gaps: Sequence[Mapping[str, Any]],
|
||||||
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||||
|
llm_specs: Sequence[Mapping[str, Any]],
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
goal_query: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Sammelt alle Lücken, für die ein KI-Anlege-Angebot sinnvoll ist."""
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
specs: List[Dict[str, Any]] = []
|
||||||
|
seen: set = set()
|
||||||
|
|
||||||
|
def add(spec: Dict[str, Any]) -> None:
|
||||||
|
key = _spec_dedupe_key(spec)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
specs.append(spec)
|
||||||
|
|
||||||
|
for gap in unfilled_gaps:
|
||||||
|
idx = find_step_pair_index(
|
||||||
|
steps,
|
||||||
|
int(gap["from_exercise_id"]),
|
||||||
|
int(gap["to_exercise_id"]),
|
||||||
|
)
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
phase = gap.get("expected_phase") or "vertiefung"
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
"source": "unfilled_gap",
|
||||||
|
"insert_after_index": idx,
|
||||||
|
"gap": dict(gap),
|
||||||
|
"phase": phase,
|
||||||
|
"title_hint": f"{topic} — {phase}",
|
||||||
|
"sketch": _default_sketch(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=steps[idx],
|
||||||
|
step_b=steps[idx + 1],
|
||||||
|
phase=str(phase),
|
||||||
|
rationale="Bibliothek enthält keine passende Brücke.",
|
||||||
|
),
|
||||||
|
"rationale": "Lücke zwischen benachbarten Schritten — keine passende Bibliotheks-Übung.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for ot in off_topic_steps:
|
||||||
|
idx = int(ot.get("step_index") or 0)
|
||||||
|
if idx <= 0 or idx >= len(steps) - 1:
|
||||||
|
continue
|
||||||
|
phase = ot.get("expected_phase") or "vertiefung"
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
"source": "off_topic",
|
||||||
|
"insert_after_index": idx - 1,
|
||||||
|
"replace_step_index": idx,
|
||||||
|
"gap": {
|
||||||
|
"expected_phase": phase,
|
||||||
|
"off_topic_title": ot.get("title"),
|
||||||
|
"off_topic_exercise_id": ot.get("exercise_id"),
|
||||||
|
},
|
||||||
|
"phase": phase,
|
||||||
|
"title_hint": f"{topic} — {phase} (Ersatz für themenfremden Schritt)",
|
||||||
|
"sketch": _default_sketch(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=steps[idx - 1],
|
||||||
|
step_b=steps[idx + 1],
|
||||||
|
phase=str(phase),
|
||||||
|
rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.",
|
||||||
|
),
|
||||||
|
"rationale": f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for spec in llm_specs:
|
||||||
|
add(dict(spec))
|
||||||
|
|
||||||
|
return specs[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def build_gap_fill_offer(
|
||||||
|
*,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
proposal: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
|
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||||
|
offer: Dict[str, Any] = {
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"source": spec.get("source"),
|
||||||
|
"insert_after_index": idx,
|
||||||
|
"replace_step_index": spec.get("replace_step_index"),
|
||||||
|
"title_hint": spec.get("title_hint"),
|
||||||
|
"sketch": spec.get("sketch"),
|
||||||
|
"phase": spec.get("phase"),
|
||||||
|
"rationale": spec.get("rationale"),
|
||||||
|
"has_ai_payload": False,
|
||||||
|
"from_title": (steps[idx].get("title") if idx < len(steps) else None),
|
||||||
|
"to_title": (steps[idx + 1].get("title") if idx + 1 < len(steps) else None),
|
||||||
|
}
|
||||||
|
if proposal:
|
||||||
|
offer["has_ai_payload"] = True
|
||||||
|
offer["proposal_key"] = proposal.get("proposal_key")
|
||||||
|
offer["ai_suggestion"] = proposal.get("ai_suggestion")
|
||||||
|
offer["proposal_title"] = proposal.get("title")
|
||||||
|
offer["proposal_summary"] = proposal.get("summary")
|
||||||
|
return offer
|
||||||
|
|
||||||
|
|
||||||
|
def apply_gap_fill_after_qa(
|
||||||
|
cur,
|
||||||
|
steps: List[Dict[str, Any]],
|
||||||
|
specs: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
include_ai_calls: bool = True,
|
||||||
|
max_ai_proposals: int = 3,
|
||||||
|
auto_insert_proposals: bool = False,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
|
||||||
|
Returns: (steps, ai_proposals, gap_fill_offers)
|
||||||
|
"""
|
||||||
|
if not specs:
|
||||||
|
return steps, [], []
|
||||||
|
|
||||||
|
out = list(steps)
|
||||||
|
proposals: List[Dict[str, Any]] = []
|
||||||
|
offers: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
|
if idx < 0 or idx >= len(out) - 1:
|
||||||
|
continue
|
||||||
|
step_a = out[idx]
|
||||||
|
step_b = out[idx + 1]
|
||||||
|
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
|
||||||
|
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=None)
|
||||||
|
offers.append(offer)
|
||||||
|
continue
|
||||||
|
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
if not gap.get("expected_phase"):
|
||||||
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||||
|
|
||||||
|
proposal: Optional[Dict[str, Any]] = None
|
||||||
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||||
|
proposal = try_suggest_ai_bridge_step(
|
||||||
|
cur,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
gap=gap,
|
||||||
|
title_hint=str(spec.get("title_hint") or ""),
|
||||||
|
sketch_hint=str(spec.get("sketch") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=proposal)
|
||||||
|
offers.append(offer)
|
||||||
|
|
||||||
|
if proposal and auto_insert_proposals:
|
||||||
|
out.insert(idx + 1, proposal)
|
||||||
|
proposals.append(
|
||||||
|
{
|
||||||
|
"inserted_after_index": idx,
|
||||||
|
"proposal_key": proposal.get("proposal_key"),
|
||||||
|
"proposal_title": proposal.get("title"),
|
||||||
|
"gap": gap,
|
||||||
|
"offer_id": offer.get("offer_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return out, proposals, offers
|
||||||
|
|
||||||
|
|
||||||
def insert_ai_proposals_for_gaps(
|
def insert_ai_proposals_for_gaps(
|
||||||
cur,
|
cur,
|
||||||
steps: list,
|
steps: list,
|
||||||
|
|
@ -141,56 +365,32 @@ def insert_ai_proposals_for_gaps(
|
||||||
brief: PlanningSemanticBrief,
|
brief: PlanningSemanticBrief,
|
||||||
max_proposals: int = 2,
|
max_proposals: int = 2,
|
||||||
) -> tuple[list, list]:
|
) -> tuple[list, list]:
|
||||||
"""Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
|
"""Legacy: Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
|
||||||
if not unfilled_gaps:
|
specs = collect_gap_fill_specs(
|
||||||
return steps, []
|
steps=steps,
|
||||||
|
unfilled_gaps=unfilled_gaps,
|
||||||
out = list(steps)
|
off_topic_steps=[],
|
||||||
proposals: list = []
|
llm_specs=[],
|
||||||
gap_by_pair = {
|
brief=brief,
|
||||||
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in unfilled_gaps
|
goal_query=goal_query,
|
||||||
}
|
)
|
||||||
|
out, proposals, _offers = apply_gap_fill_after_qa(
|
||||||
i = 0
|
cur,
|
||||||
while i < len(out) - 1 and len(proposals) < max_proposals:
|
steps,
|
||||||
a = out[i]
|
specs,
|
||||||
b = out[i + 1]
|
goal_query=goal_query,
|
||||||
if a.get("is_ai_proposal") or b.get("is_ai_proposal"):
|
brief=brief,
|
||||||
i += 1
|
include_ai_calls=True,
|
||||||
continue
|
max_ai_proposals=max_proposals,
|
||||||
key = (int(a["exercise_id"]), int(b["exercise_id"]))
|
auto_insert_proposals=True,
|
||||||
gap = gap_by_pair.get(key)
|
)
|
||||||
if not gap:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
proposal = try_suggest_ai_bridge_step(
|
|
||||||
cur,
|
|
||||||
goal_query=goal_query,
|
|
||||||
brief=brief,
|
|
||||||
step_a=a,
|
|
||||||
step_b=b,
|
|
||||||
gap=gap,
|
|
||||||
)
|
|
||||||
if not proposal:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
out.insert(i + 1, proposal)
|
|
||||||
proposals.append(
|
|
||||||
{
|
|
||||||
"inserted_after_index": i,
|
|
||||||
"proposal_key": proposal.get("proposal_key"),
|
|
||||||
"proposal_title": proposal.get("title"),
|
|
||||||
"gap": gap,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
i += 2
|
|
||||||
|
|
||||||
return out, proposals
|
return out, proposals
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"apply_gap_fill_after_qa",
|
||||||
|
"build_gap_fill_offer",
|
||||||
|
"collect_gap_fill_specs",
|
||||||
"insert_ai_proposals_for_gaps",
|
"insert_ai_proposals_for_gaps",
|
||||||
"try_suggest_ai_bridge_step",
|
"try_suggest_ai_bridge_step",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@ from planning_exercise_profiles import PlanningTargetProfile
|
||||||
from planning_exercise_path_qa import (
|
from planning_exercise_path_qa import (
|
||||||
apply_llm_path_reorder,
|
apply_llm_path_reorder,
|
||||||
build_path_qa_summary,
|
build_path_qa_summary,
|
||||||
|
detect_off_topic_steps,
|
||||||
detect_path_gaps,
|
detect_path_gaps,
|
||||||
insert_bridge_exercises,
|
insert_bridge_exercises,
|
||||||
|
parse_llm_suggested_new_exercises,
|
||||||
try_llm_qa_progression_path,
|
try_llm_qa_progression_path,
|
||||||
)
|
)
|
||||||
from planning_exercise_path_ai_fill import insert_ai_proposals_for_gaps
|
from planning_exercise_path_ai_fill import apply_gap_fill_after_qa, collect_gap_fill_specs
|
||||||
from planning_exercise_retrieval import run_multistage_planning_retrieval
|
from planning_exercise_retrieval import run_multistage_planning_retrieval
|
||||||
from planning_exercise_semantics import (
|
from planning_exercise_semantics import (
|
||||||
PlanningSemanticBrief,
|
PlanningSemanticBrief,
|
||||||
|
|
@ -394,6 +396,8 @@ def suggest_progression_path(
|
||||||
gaps: List[Dict[str, Any]] = []
|
gaps: List[Dict[str, Any]] = []
|
||||||
bridge_inserts: List[Dict[str, Any]] = []
|
bridge_inserts: List[Dict[str, Any]] = []
|
||||||
ai_proposals: List[Dict[str, Any]] = []
|
ai_proposals: List[Dict[str, Any]] = []
|
||||||
|
gap_fill_offers: List[Dict[str, Any]] = []
|
||||||
|
off_topic_steps: List[Dict[str, Any]] = []
|
||||||
llm_qa: Optional[Dict[str, Any]] = None
|
llm_qa: Optional[Dict[str, Any]] = None
|
||||||
llm_qa_applied = False
|
llm_qa_applied = False
|
||||||
reorder_applied = False
|
reorder_applied = False
|
||||||
|
|
@ -424,15 +428,6 @@ def suggest_progression_path(
|
||||||
bridge_search_fn=bridge_fn,
|
bridge_search_fn=bridge_fn,
|
||||||
)
|
)
|
||||||
|
|
||||||
if body.include_ai_gap_fill and unfilled_gaps:
|
|
||||||
steps, ai_proposals = insert_ai_proposals_for_gaps(
|
|
||||||
cur,
|
|
||||||
steps,
|
|
||||||
unfilled_gaps,
|
|
||||||
goal_query=goal_query,
|
|
||||||
brief=semantic_brief,
|
|
||||||
)
|
|
||||||
|
|
||||||
if body.include_llm_path_qa:
|
if body.include_llm_path_qa:
|
||||||
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
|
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -452,10 +447,39 @@ def suggest_progression_path(
|
||||||
if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45):
|
if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45):
|
||||||
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
|
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
|
||||||
|
|
||||||
|
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
|
||||||
|
llm_gap_specs = parse_llm_suggested_new_exercises(
|
||||||
|
llm_qa,
|
||||||
|
brief=semantic_brief,
|
||||||
|
step_count=len(steps),
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.include_ai_gap_fill:
|
||||||
|
gap_specs = collect_gap_fill_specs(
|
||||||
|
steps=steps,
|
||||||
|
unfilled_gaps=unfilled_gaps,
|
||||||
|
off_topic_steps=off_topic_steps,
|
||||||
|
llm_specs=llm_gap_specs,
|
||||||
|
brief=semantic_brief,
|
||||||
|
goal_query=goal_query,
|
||||||
|
)
|
||||||
|
steps, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
|
||||||
|
cur,
|
||||||
|
steps,
|
||||||
|
gap_specs,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=semantic_brief,
|
||||||
|
include_ai_calls=True,
|
||||||
|
max_ai_proposals=3,
|
||||||
|
auto_insert_proposals=False,
|
||||||
|
)
|
||||||
|
|
||||||
path_qa = build_path_qa_summary(
|
path_qa = build_path_qa_summary(
|
||||||
gaps=gaps,
|
gaps=gaps,
|
||||||
bridge_inserts=bridge_inserts,
|
bridge_inserts=bridge_inserts,
|
||||||
ai_proposals=ai_proposals,
|
ai_proposals=ai_proposals,
|
||||||
|
gap_fill_offers=gap_fill_offers,
|
||||||
|
off_topic_steps=off_topic_steps,
|
||||||
llm_qa=llm_qa,
|
llm_qa=llm_qa,
|
||||||
llm_applied=llm_qa_applied,
|
llm_applied=llm_qa_applied,
|
||||||
reorder_applied=reorder_applied,
|
reorder_applied=reorder_applied,
|
||||||
|
|
@ -472,6 +496,8 @@ def suggest_progression_path(
|
||||||
retrieval_parts.append("path_reorder")
|
retrieval_parts.append("path_reorder")
|
||||||
if ai_proposals:
|
if ai_proposals:
|
||||||
retrieval_parts.append("ai_gap_fill")
|
retrieval_parts.append("ai_gap_fill")
|
||||||
|
if gap_fill_offers:
|
||||||
|
retrieval_parts.append("gap_fill_offers")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"goal_query": goal_query,
|
"goal_query": goal_query,
|
||||||
|
|
@ -484,6 +510,7 @@ def suggest_progression_path(
|
||||||
"query_intent_summary": first_intent_summary,
|
"query_intent_summary": first_intent_summary,
|
||||||
"progression_graph_id": body.progression_graph_id,
|
"progression_graph_id": body.progression_graph_id,
|
||||||
"path_qa": path_qa,
|
"path_qa": path_qa,
|
||||||
|
"gap_fill_offers": gap_fill_offers,
|
||||||
"retrieval_phase": "+".join(retrieval_parts),
|
"retrieval_phase": "+".join(retrieval_parts),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from openrouter_chat import (
|
||||||
from planning_exercise_semantics import (
|
from planning_exercise_semantics import (
|
||||||
PlanningSemanticBrief,
|
PlanningSemanticBrief,
|
||||||
brief_to_summary_dict,
|
brief_to_summary_dict,
|
||||||
|
exercise_passes_path_semantic_gate,
|
||||||
score_exercise_semantic_relevance,
|
score_exercise_semantic_relevance,
|
||||||
step_phase_for_index,
|
step_phase_for_index,
|
||||||
)
|
)
|
||||||
|
|
@ -230,6 +231,18 @@ def insert_bridge_exercises(
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
bridge_sem = float(bridge_hit.get("semantic_score") or 0.0)
|
||||||
|
if brief.semantic_strength >= 0.55 and not exercise_passes_path_semantic_gate(
|
||||||
|
semantic_score=bridge_sem,
|
||||||
|
title=str(bridge_hit.get("title") or ""),
|
||||||
|
summary=str(bridge_hit.get("summary") or ""),
|
||||||
|
brief=brief,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
unfilled.append({**gap, "weak_bridge_rejected": True, "bridge_title": bridge_hit.get("title")})
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
bridge_step = {
|
bridge_step = {
|
||||||
"exercise_id": int(bridge_hit["id"]),
|
"exercise_id": int(bridge_hit["id"]),
|
||||||
"variant_id": bridge_hit.get("suggested_variant_id"),
|
"variant_id": bridge_hit.get("suggested_variant_id"),
|
||||||
|
|
@ -351,16 +364,133 @@ def apply_llm_path_reorder(
|
||||||
return [steps[i] for i in indices], True, notes
|
return [steps[i] for i in indices], True, notes
|
||||||
|
|
||||||
|
|
||||||
|
_OFF_TOPIC_SEMANTIC_MAX = 0.10
|
||||||
|
|
||||||
|
|
||||||
|
def detect_off_topic_steps(
|
||||||
|
cur,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri)."""
|
||||||
|
if brief.semantic_strength < 0.55 or len(steps) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
off_topic: List[Dict[str, Any]] = []
|
||||||
|
total = len(steps)
|
||||||
|
for idx, step in enumerate(steps):
|
||||||
|
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
|
||||||
|
continue
|
||||||
|
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
|
||||||
|
phase = step_phase_for_index(brief, idx, total)
|
||||||
|
sem, sem_reasons = score_exercise_semantic_relevance(
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
variant_names=bundle["variant_names"],
|
||||||
|
brief=brief,
|
||||||
|
step_phase=phase,
|
||||||
|
)
|
||||||
|
if exercise_passes_path_semantic_gate(
|
||||||
|
semantic_score=sem,
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
brief=brief,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if sem > _OFF_TOPIC_SEMANTIC_MAX:
|
||||||
|
continue
|
||||||
|
off_topic.append(
|
||||||
|
{
|
||||||
|
"step_index": idx,
|
||||||
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
"title": step.get("title") or bundle["title"],
|
||||||
|
"semantic_score": round(sem, 4),
|
||||||
|
"expected_phase": phase,
|
||||||
|
"issue": "off_topic",
|
||||||
|
"reasons": sem_reasons[:3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return off_topic
|
||||||
|
|
||||||
|
|
||||||
|
def parse_llm_suggested_new_exercises(
|
||||||
|
llm_qa: Optional[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_count: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Strukturierte Neuanlage-Vorschläge aus LLM-Pfad-QS."""
|
||||||
|
if not llm_qa:
|
||||||
|
return []
|
||||||
|
raw = llm_qa.get("suggested_new_exercises")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for item in raw[:5]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
title_hint = str(item.get("title_hint") or item.get("title") or "").strip()
|
||||||
|
if len(title_hint) < 3:
|
||||||
|
title_hint = f"{topic} — Zwischenschritt"
|
||||||
|
sketch = str(item.get("sketch") or item.get("goal_hint") or item.get("rationale") or "").strip()
|
||||||
|
phase = str(item.get("phase") or item.get("expected_phase") or "vertiefung").strip()
|
||||||
|
rationale = str(item.get("rationale") or "").strip()
|
||||||
|
insert_after = item.get("insert_after_step_index")
|
||||||
|
if insert_after is None:
|
||||||
|
insert_after = item.get("insert_after_index")
|
||||||
|
try:
|
||||||
|
insert_idx = int(insert_after) if insert_after is not None else max(0, step_count // 2 - 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
insert_idx = max(0, step_count // 2 - 1)
|
||||||
|
insert_idx = max(0, min(step_count - 2, insert_idx))
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"source": "llm_suggested",
|
||||||
|
"insert_after_index": insert_idx,
|
||||||
|
"title_hint": title_hint[:280],
|
||||||
|
"sketch": sketch[:1200],
|
||||||
|
"phase": phase,
|
||||||
|
"rationale": rationale[:500],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_step_pair_index(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
from_exercise_id: int,
|
||||||
|
to_exercise_id: int,
|
||||||
|
) -> Optional[int]:
|
||||||
|
for i in range(len(steps) - 1):
|
||||||
|
a = steps[i]
|
||||||
|
b = steps[i + 1]
|
||||||
|
if a.get("exercise_id") is None or b.get("exercise_id") is None:
|
||||||
|
continue
|
||||||
|
if int(a["exercise_id"]) == int(from_exercise_id) and int(b["exercise_id"]) == int(to_exercise_id):
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_path_qa_summary(
|
def build_path_qa_summary(
|
||||||
*,
|
*,
|
||||||
gaps: Sequence[Mapping[str, Any]],
|
gaps: Sequence[Mapping[str, Any]],
|
||||||
bridge_inserts: Sequence[Mapping[str, Any]],
|
bridge_inserts: Sequence[Mapping[str, Any]],
|
||||||
ai_proposals: Sequence[Mapping[str, Any]],
|
ai_proposals: Sequence[Mapping[str, Any]],
|
||||||
|
gap_fill_offers: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
llm_qa: Optional[Mapping[str, Any]],
|
llm_qa: Optional[Mapping[str, Any]],
|
||||||
llm_applied: bool,
|
llm_applied: bool,
|
||||||
reorder_applied: bool = False,
|
reorder_applied: bool = False,
|
||||||
reorder_notes: Optional[Sequence[str]] = None,
|
reorder_notes: Optional[Sequence[str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
offers = list(gap_fill_offers or [])
|
||||||
|
off_topic = list(off_topic_steps or [])
|
||||||
summary: Dict[str, Any] = {
|
summary: Dict[str, Any] = {
|
||||||
"gap_count": len(gaps),
|
"gap_count": len(gaps),
|
||||||
"large_gaps": list(gaps),
|
"large_gaps": list(gaps),
|
||||||
|
|
@ -368,6 +498,10 @@ def build_path_qa_summary(
|
||||||
"bridge_inserts": list(bridge_inserts),
|
"bridge_inserts": list(bridge_inserts),
|
||||||
"ai_proposal_count": len(ai_proposals),
|
"ai_proposal_count": len(ai_proposals),
|
||||||
"ai_proposals": list(ai_proposals),
|
"ai_proposals": list(ai_proposals),
|
||||||
|
"gap_fill_offer_count": len(offers),
|
||||||
|
"gap_fill_offers": offers,
|
||||||
|
"off_topic_count": len(off_topic),
|
||||||
|
"off_topic_steps": off_topic,
|
||||||
"llm_qa_applied": llm_applied,
|
"llm_qa_applied": llm_applied,
|
||||||
"reorder_applied": reorder_applied,
|
"reorder_applied": reorder_applied,
|
||||||
"reorder_notes": list(reorder_notes or []),
|
"reorder_notes": list(reorder_notes or []),
|
||||||
|
|
@ -379,20 +513,29 @@ def build_path_qa_summary(
|
||||||
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
||||||
summary["topic_coverage"] = llm_qa.get("topic_coverage")
|
summary["topic_coverage"] = llm_qa.get("topic_coverage")
|
||||||
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
|
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
|
||||||
|
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
|
||||||
else:
|
else:
|
||||||
summary["overall_ok"] = len(gaps) == 0
|
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
|
||||||
summary["issues"] = [
|
summary["issues"] = [
|
||||||
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
|
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
|
||||||
for g in gaps
|
for g in gaps
|
||||||
] if gaps else []
|
] if gaps else []
|
||||||
|
if off_topic:
|
||||||
|
summary["issues"] = list(summary["issues"]) + [
|
||||||
|
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
|
||||||
|
for o in off_topic
|
||||||
|
]
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"apply_llm_path_reorder",
|
"apply_llm_path_reorder",
|
||||||
"build_path_qa_summary",
|
"build_path_qa_summary",
|
||||||
|
"detect_off_topic_steps",
|
||||||
"detect_path_gaps",
|
"detect_path_gaps",
|
||||||
|
"find_step_pair_index",
|
||||||
"insert_bridge_exercises",
|
"insert_bridge_exercises",
|
||||||
"measure_step_transition_gap",
|
"measure_step_transition_gap",
|
||||||
|
"parse_llm_suggested_new_exercises",
|
||||||
"try_llm_qa_progression_path",
|
"try_llm_qa_progression_path",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
519
backend/routers/admin_user_content.py
Normal file
519
backend/routers/admin_user_content.py
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
"""
|
||||||
|
Superadmin API: Übersicht und Moderation nutzerangelegter Inhalte (inkl. private).
|
||||||
|
|
||||||
|
# ACCESS_LAYER exempt: Plattform-weites Superadmin-Werkzeug ohne TenantContext.
|
||||||
|
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from club_tenancy import is_superadmin
|
||||||
|
from db import get_db, get_cursor, r2d
|
||||||
|
from media_lifecycle import superadmin_hard_delete_media_asset
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/user-content", tags=["admin_user_content"])
|
||||||
|
|
||||||
|
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
|
||||||
|
_VALID_EXERCISE_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
|
||||||
|
_VALID_MATURITY_STATUS = frozenset({"draft", "active", "archived"})
|
||||||
|
_VALID_MEDIA_RIGHTS = frozenset({"legacy_unreviewed", "declared", "blocked"})
|
||||||
|
_VALID_MEDIA_LIFECYCLE = frozenset({"active", "trash_soft", "trash_hidden"})
|
||||||
|
_MAX_ITEMS_LIMIT = 100
|
||||||
|
|
||||||
|
ContentType = Literal[
|
||||||
|
"exercise",
|
||||||
|
"training_module",
|
||||||
|
"framework_program",
|
||||||
|
"progression_graph",
|
||||||
|
"plan_template",
|
||||||
|
"maturity_model",
|
||||||
|
"media_asset",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONTENT_SPECS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"exercise": {
|
||||||
|
"label": "Übung",
|
||||||
|
"table": "exercises",
|
||||||
|
"creator_col": "created_by",
|
||||||
|
"title_col": "title",
|
||||||
|
"status_col": "status",
|
||||||
|
"visibility_col": "visibility",
|
||||||
|
"club_col": "club_id",
|
||||||
|
"has_status": True,
|
||||||
|
"has_visibility": True,
|
||||||
|
"status_values": sorted(_VALID_EXERCISE_STATUS),
|
||||||
|
},
|
||||||
|
"training_module": {
|
||||||
|
"label": "Trainingsmodul",
|
||||||
|
"table": "training_modules",
|
||||||
|
"creator_col": "created_by",
|
||||||
|
"title_col": "title",
|
||||||
|
"status_col": None,
|
||||||
|
"visibility_col": "visibility",
|
||||||
|
"club_col": "club_id",
|
||||||
|
"has_status": False,
|
||||||
|
"has_visibility": True,
|
||||||
|
"status_values": [],
|
||||||
|
},
|
||||||
|
"framework_program": {
|
||||||
|
"label": "Rahmenprogramm",
|
||||||
|
"table": "training_framework_programs",
|
||||||
|
"creator_col": "created_by",
|
||||||
|
"title_col": "title",
|
||||||
|
"status_col": None,
|
||||||
|
"visibility_col": "visibility",
|
||||||
|
"club_col": "club_id",
|
||||||
|
"has_status": False,
|
||||||
|
"has_visibility": True,
|
||||||
|
"status_values": [],
|
||||||
|
},
|
||||||
|
"progression_graph": {
|
||||||
|
"label": "Progressionspfad",
|
||||||
|
"table": "exercise_progression_graphs",
|
||||||
|
"creator_col": "created_by",
|
||||||
|
"title_col": "name",
|
||||||
|
"status_col": None,
|
||||||
|
"visibility_col": "visibility",
|
||||||
|
"club_col": "club_id",
|
||||||
|
"has_status": False,
|
||||||
|
"has_visibility": True,
|
||||||
|
"status_values": [],
|
||||||
|
},
|
||||||
|
"plan_template": {
|
||||||
|
"label": "Trainingsvorlage",
|
||||||
|
"table": "training_plan_templates",
|
||||||
|
"creator_col": "created_by",
|
||||||
|
"title_col": "name",
|
||||||
|
"status_col": None,
|
||||||
|
"visibility_col": "visibility",
|
||||||
|
"club_col": "club_id",
|
||||||
|
"has_status": False,
|
||||||
|
"has_visibility": True,
|
||||||
|
"status_values": [],
|
||||||
|
},
|
||||||
|
"maturity_model": {
|
||||||
|
"label": "Reifegradmodell",
|
||||||
|
"table": "maturity_models",
|
||||||
|
"creator_col": "created_by",
|
||||||
|
"title_col": "name",
|
||||||
|
"status_col": "status",
|
||||||
|
"visibility_col": None,
|
||||||
|
"club_col": "club_id",
|
||||||
|
"has_status": True,
|
||||||
|
"has_visibility": False,
|
||||||
|
"status_values": sorted(_VALID_MATURITY_STATUS),
|
||||||
|
},
|
||||||
|
"media_asset": {
|
||||||
|
"label": "Medium",
|
||||||
|
"table": "media_assets",
|
||||||
|
"creator_col": "uploaded_by_profile_id",
|
||||||
|
"title_col": "original_filename",
|
||||||
|
"status_col": "rights_status",
|
||||||
|
"visibility_col": "visibility",
|
||||||
|
"club_col": "club_id",
|
||||||
|
"extra_col": "lifecycle_state",
|
||||||
|
"has_status": True,
|
||||||
|
"has_visibility": True,
|
||||||
|
"status_values": sorted(_VALID_MEDIA_RIGHTS),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_superadmin(session: dict) -> dict:
|
||||||
|
role = (session.get("role") or "").strip().lower()
|
||||||
|
if not is_superadmin(role):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur Superadmins")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _spec(content_type: str) -> Dict[str, Any]:
|
||||||
|
key = (content_type or "").strip().lower()
|
||||||
|
spec = _CONTENT_SPECS.get(key)
|
||||||
|
if not spec:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp")
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def _types_for_filters(
|
||||||
|
content_type: Optional[str],
|
||||||
|
status: Optional[str],
|
||||||
|
) -> List[str]:
|
||||||
|
if content_type and content_type != "all":
|
||||||
|
return [content_type]
|
||||||
|
types = list(_CONTENT_SPECS.keys())
|
||||||
|
if status:
|
||||||
|
types = [t for t in types if _CONTENT_SPECS[t].get("has_status")]
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
def _build_type_select(spec: Dict[str, Any], content_type: str) -> str:
|
||||||
|
title = spec["title_col"]
|
||||||
|
creator = spec["creator_col"]
|
||||||
|
status = spec.get("status_col")
|
||||||
|
visibility = spec.get("visibility_col")
|
||||||
|
club = spec.get("club_col")
|
||||||
|
extra = spec.get("extra_col")
|
||||||
|
status_sql = f"t.{status}" if status else "NULL"
|
||||||
|
vis_sql = f"t.{visibility}" if visibility else "NULL"
|
||||||
|
club_sql = f"t.{club}" if club else "NULL"
|
||||||
|
extra_sql = f"t.{extra}" if extra else "NULL"
|
||||||
|
return f"""
|
||||||
|
SELECT
|
||||||
|
'{content_type}' AS content_type,
|
||||||
|
t.id,
|
||||||
|
t.{title} AS title,
|
||||||
|
t.{creator} AS profile_id,
|
||||||
|
{status_sql} AS status,
|
||||||
|
{vis_sql} AS visibility,
|
||||||
|
{club_sql} AS club_id,
|
||||||
|
{extra_sql} AS extra_status,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at
|
||||||
|
FROM {spec['table']} t
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _append_filters(
|
||||||
|
where: List[str],
|
||||||
|
params: List[Any],
|
||||||
|
*,
|
||||||
|
spec: Dict[str, Any],
|
||||||
|
profile_id: Optional[int],
|
||||||
|
visibility: Optional[str],
|
||||||
|
status: Optional[str],
|
||||||
|
search: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
creator = spec["creator_col"]
|
||||||
|
if profile_id is not None:
|
||||||
|
where.append(f"t.{creator} = %s")
|
||||||
|
params.append(profile_id)
|
||||||
|
|
||||||
|
if visibility and visibility != "all":
|
||||||
|
vis_col = spec.get("visibility_col")
|
||||||
|
if vis_col:
|
||||||
|
where.append(f"t.{vis_col} = %s")
|
||||||
|
params.append(visibility)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
st_col = spec.get("status_col")
|
||||||
|
if st_col:
|
||||||
|
where.append(f"t.{st_col} = %s")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
title_col = spec["title_col"]
|
||||||
|
where.append(f"t.{title_col} ILIKE %s")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
|
||||||
|
def _exercise_delete_usage_message(cur, exercise_id: int) -> str:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items,
|
||||||
|
(SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items,
|
||||||
|
(SELECT COUNT(*)::int FROM exercise_progression_edges
|
||||||
|
WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges
|
||||||
|
""",
|
||||||
|
(exercise_id, exercise_id, exercise_id, exercise_id),
|
||||||
|
)
|
||||||
|
row = r2d(cur.fetchone() or {})
|
||||||
|
parts = []
|
||||||
|
if int(row.get("block_items") or 0):
|
||||||
|
parts.append(f"{row['block_items']}× in Übungsblöcken")
|
||||||
|
if int(row.get("section_items") or 0):
|
||||||
|
parts.append(f"{row['section_items']}× in Trainingsplänen oder Rahmenabläufen")
|
||||||
|
if int(row.get("prog_edges") or 0):
|
||||||
|
parts.append(f"{row['prog_edges']}× in Progressionsgraphen (Kanten)")
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
"Die Übung wird noch verwendet und kann nicht gelöscht werden. "
|
||||||
|
"Bitte auf „archiviert“ setzen. Verwendung: " + ", ".join(parts) + "."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserContentPatchBody(BaseModel):
|
||||||
|
status: Optional[str] = None
|
||||||
|
visibility: Optional[str] = None
|
||||||
|
lifecycle_state: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def at_least_one_field(self):
|
||||||
|
if self.status is None and self.visibility is None and self.lifecycle_state is None:
|
||||||
|
raise ValueError("Mindestens eines der Felder status, visibility oder lifecycle_state angeben")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meta")
|
||||||
|
def get_user_content_meta(session: dict = Depends(require_auth)):
|
||||||
|
"""Metadaten zu unterstützten Inhaltstypen."""
|
||||||
|
_require_superadmin(session)
|
||||||
|
types = []
|
||||||
|
for key, spec in _CONTENT_SPECS.items():
|
||||||
|
types.append(
|
||||||
|
{
|
||||||
|
"id": key,
|
||||||
|
"label": spec["label"],
|
||||||
|
"has_status": spec["has_status"],
|
||||||
|
"has_visibility": spec["has_visibility"],
|
||||||
|
"status_values": spec.get("status_values") or [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"content_types": types}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users-summary")
|
||||||
|
def list_users_content_summary(session: dict = Depends(require_auth)):
|
||||||
|
"""Anzahl angelegter Inhalte je Nutzer (alle Sichtbarkeiten)."""
|
||||||
|
_require_superadmin(session)
|
||||||
|
|
||||||
|
count_exprs: List[str] = []
|
||||||
|
for key, spec in _CONTENT_SPECS.items():
|
||||||
|
creator = spec["creator_col"]
|
||||||
|
count_exprs.append(
|
||||||
|
f"(SELECT COUNT(*)::int FROM {spec['table']} WHERE {creator} = p.id) AS {key}_count"
|
||||||
|
)
|
||||||
|
counts_sql = ",\n ".join(count_exprs)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.email,
|
||||||
|
p.role,
|
||||||
|
p.created_at,
|
||||||
|
{counts_sql},
|
||||||
|
(
|
||||||
|
{" + ".join(f"COALESCE((SELECT COUNT(*)::int FROM {spec['table']} WHERE {spec['creator_col']} = p.id), 0)" for spec in _CONTENT_SPECS.values())}
|
||||||
|
) AS total_count
|
||||||
|
FROM profiles p
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM exercises e WHERE e.created_by = p.id
|
||||||
|
UNION ALL SELECT 1 FROM training_modules tm WHERE tm.created_by = p.id
|
||||||
|
UNION ALL SELECT 1 FROM training_framework_programs fp WHERE fp.created_by = p.id
|
||||||
|
UNION ALL SELECT 1 FROM exercise_progression_graphs pg WHERE pg.created_by = p.id
|
||||||
|
UNION ALL SELECT 1 FROM training_plan_templates pt WHERE pt.created_by = p.id
|
||||||
|
UNION ALL SELECT 1 FROM maturity_models mm WHERE mm.created_by = p.id
|
||||||
|
UNION ALL SELECT 1 FROM media_assets ma WHERE ma.uploaded_by_profile_id = p.id
|
||||||
|
)
|
||||||
|
ORDER BY total_count DESC, COALESCE(lower(trim(p.email)), ''), p.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
d = r2d(r)
|
||||||
|
counts = {k: int(d.pop(f"{k}_count") or 0) for k in _CONTENT_SPECS}
|
||||||
|
d["counts_by_type"] = counts
|
||||||
|
d["total_count"] = int(d.get("total_count") or 0)
|
||||||
|
rows.append(d)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items")
|
||||||
|
def list_user_content_items(
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
profile_id: Optional[int] = Query(default=None, ge=1),
|
||||||
|
content_type: str = Query(default="all"),
|
||||||
|
visibility: Optional[str] = Query(default="all"),
|
||||||
|
status: Optional[str] = Query(default=None),
|
||||||
|
search: Optional[str] = Query(default=None, max_length=200),
|
||||||
|
limit: int = Query(default=50, ge=1, le=_MAX_ITEMS_LIMIT),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
):
|
||||||
|
"""Paginierte Inhaltsliste — Superadmin sieht auch private Inhalte."""
|
||||||
|
_require_superadmin(session)
|
||||||
|
|
||||||
|
ct_raw = (content_type or "all").strip().lower()
|
||||||
|
if ct_raw != "all" and ct_raw not in _CONTENT_SPECS:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp")
|
||||||
|
|
||||||
|
vis_raw = (visibility or "all").strip().lower()
|
||||||
|
if vis_raw not in ("all", *_VALID_VISIBILITY):
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Sichtbarkeits-Filter")
|
||||||
|
|
||||||
|
types = _types_for_filters(ct_raw if ct_raw != "all" else None, status)
|
||||||
|
if not types:
|
||||||
|
return {"items": [], "total": 0, "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
unions: List[str] = []
|
||||||
|
all_params: List[Any] = []
|
||||||
|
for tkey in types:
|
||||||
|
spec = _CONTENT_SPECS[tkey]
|
||||||
|
where: List[str] = ["TRUE"]
|
||||||
|
params: List[Any] = []
|
||||||
|
_append_filters(
|
||||||
|
where,
|
||||||
|
params,
|
||||||
|
spec=spec,
|
||||||
|
profile_id=profile_id,
|
||||||
|
visibility=vis_raw,
|
||||||
|
status=(status or "").strip().lower() or None,
|
||||||
|
search=(search or "").strip() or None,
|
||||||
|
)
|
||||||
|
unions.append(_build_type_select(spec, tkey) + " WHERE " + " AND ".join(where))
|
||||||
|
all_params.extend(params)
|
||||||
|
|
||||||
|
union_sql = " UNION ALL ".join(unions)
|
||||||
|
count_sql = f"SELECT COUNT(*)::int AS c FROM ({union_sql}) sub"
|
||||||
|
list_sql = f"""
|
||||||
|
SELECT sub.*,
|
||||||
|
p.name AS profile_name,
|
||||||
|
p.email AS profile_email,
|
||||||
|
c.name AS club_name
|
||||||
|
FROM ({union_sql}) sub
|
||||||
|
LEFT JOIN profiles p ON p.id = sub.profile_id
|
||||||
|
LEFT JOIN clubs c ON c.id = sub.club_id
|
||||||
|
ORDER BY sub.updated_at DESC NULLS LAST, sub.id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(count_sql, tuple(all_params))
|
||||||
|
count_row = cur.fetchone()
|
||||||
|
total = int(r2d(count_row).get("c") or 0)
|
||||||
|
|
||||||
|
cur.execute(list_sql, tuple(all_params + [limit, offset]))
|
||||||
|
items = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
d = r2d(r)
|
||||||
|
d["type_label"] = _CONTENT_SPECS[d["content_type"]]["label"]
|
||||||
|
items.append(d)
|
||||||
|
|
||||||
|
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{content_type}/{item_id}")
|
||||||
|
def patch_user_content_item(
|
||||||
|
content_type: ContentType,
|
||||||
|
item_id: int,
|
||||||
|
body: UserContentPatchBody,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Status und/oder Sichtbarkeit setzen (Superadmin)."""
|
||||||
|
_require_superadmin(session)
|
||||||
|
spec = _spec(content_type)
|
||||||
|
table = spec["table"]
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(f"SELECT * FROM {table} WHERE id = %s", (item_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
|
||||||
|
current = r2d(row)
|
||||||
|
|
||||||
|
fields: List[str] = []
|
||||||
|
params: List[Any] = []
|
||||||
|
|
||||||
|
if body.status is not None:
|
||||||
|
st_col = spec.get("status_col")
|
||||||
|
if not st_col:
|
||||||
|
raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keinen Status")
|
||||||
|
st = body.status.strip().lower()
|
||||||
|
if content_type == "exercise" and st not in _VALID_EXERCISE_STATUS:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Übungs-Status")
|
||||||
|
if content_type == "maturity_model" and st not in _VALID_MATURITY_STATUS:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Modell-Status")
|
||||||
|
if content_type == "media_asset" and st not in _VALID_MEDIA_RIGHTS:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Medien-Rechte-Status")
|
||||||
|
fields.append(f"{st_col} = %s")
|
||||||
|
params.append(st)
|
||||||
|
|
||||||
|
if body.visibility is not None:
|
||||||
|
vis_col = spec.get("visibility_col")
|
||||||
|
if not vis_col:
|
||||||
|
raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keine Sichtbarkeit")
|
||||||
|
vis = body.visibility.strip().lower()
|
||||||
|
if vis not in _VALID_VISIBILITY:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit")
|
||||||
|
if vis == "club" and not current.get(spec.get("club_col") or "club_id"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Vereins-Sichtbarkeit erfordert eine Vereinszuordnung (club_id).",
|
||||||
|
)
|
||||||
|
fields.append(f"{vis_col} = %s")
|
||||||
|
params.append(vis)
|
||||||
|
|
||||||
|
if body.lifecycle_state is not None:
|
||||||
|
if content_type != "media_asset":
|
||||||
|
raise HTTPException(status_code=400, detail="Lifecycle nur für Medien")
|
||||||
|
lc = body.lifecycle_state.strip().lower()
|
||||||
|
if lc not in _VALID_MEDIA_LIFECYCLE:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Lifecycle-Status")
|
||||||
|
fields.append("lifecycle_state = %s")
|
||||||
|
params.append(lc)
|
||||||
|
if lc == "active":
|
||||||
|
fields.extend(
|
||||||
|
[
|
||||||
|
"trash_soft_at = NULL",
|
||||||
|
"trash_hidden_at = NULL",
|
||||||
|
"purge_after_at = NULL",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine gültigen Änderungen")
|
||||||
|
|
||||||
|
fields.append("updated_at = NOW()")
|
||||||
|
params.append(item_id)
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE {table} SET {', '.join(fields)} WHERE id = %s RETURNING id",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "content_type": content_type, "id": item_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{content_type}/{item_id}")
|
||||||
|
def delete_user_content_item(
|
||||||
|
content_type: ContentType,
|
||||||
|
item_id: int,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Inhalt endgültig löschen (Superadmin)."""
|
||||||
|
_require_superadmin(session)
|
||||||
|
spec = _spec(content_type)
|
||||||
|
table = spec["table"]
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
if content_type == "exercise":
|
||||||
|
cur.execute("SELECT id FROM exercises WHERE id = %s", (item_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
|
usage_msg = _exercise_delete_usage_message(cur, item_id)
|
||||||
|
if usage_msg:
|
||||||
|
raise HTTPException(status_code=409, detail=usage_msg)
|
||||||
|
cur.execute("DELETE FROM exercises WHERE id = %s", (item_id,))
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if content_type == "media_asset":
|
||||||
|
cur.execute("SELECT id FROM media_assets WHERE id = %s", (item_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||||
|
ok = superadmin_hard_delete_media_asset(cur, conn, item_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
cur.execute(f"DELETE FROM {table} WHERE id = %s RETURNING id", (item_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "content_type": content_type, "id": item_id}
|
||||||
|
|
@ -26,6 +26,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
||||||
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||||
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
||||||
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
|
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
|
||||||
|
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
|
||||||
"catalogs.py",
|
"catalogs.py",
|
||||||
"skills.py",
|
"skills.py",
|
||||||
"maturity_models.py",
|
"maturity_models.py",
|
||||||
|
|
|
||||||
64
backend/tests/test_planning_exercise_path_ai_fill.py
Normal file
64
backend/tests/test_planning_exercise_path_ai_fill.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
|
||||||
|
from planning_exercise_path_ai_fill import collect_gap_fill_specs
|
||||||
|
from planning_exercise_path_qa import parse_llm_suggested_new_exercises
|
||||||
|
from planning_exercise_semantics import build_semantic_brief
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_llm_suggested_new_exercises():
|
||||||
|
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||||
|
llm_qa = {
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraft am Sandsack",
|
||||||
|
"sketch": "Kraft und Schnelligkeit",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 1,
|
||||||
|
"rationale": "Zwischenschritt",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
specs = parse_llm_suggested_new_exercises(llm_qa, brief=brief, step_count=5)
|
||||||
|
assert len(specs) == 1
|
||||||
|
assert specs[0]["insert_after_index"] == 1
|
||||||
|
assert "Mae Geri" in specs[0]["title_hint"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_gap_fill_specs_off_topic_and_unfilled():
|
||||||
|
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||||
|
steps = [
|
||||||
|
{"exercise_id": 1, "title": "Mae Geri Kihon"},
|
||||||
|
{"exercise_id": 2, "title": "Präzision"},
|
||||||
|
{"exercise_id": 3, "title": "One Leg Squat"},
|
||||||
|
{"exercise_id": 4, "title": "Gleichgewichtstritt"},
|
||||||
|
]
|
||||||
|
unfilled = [
|
||||||
|
{
|
||||||
|
"from_exercise_id": 2,
|
||||||
|
"to_exercise_id": 3,
|
||||||
|
"expected_phase": "vertiefung",
|
||||||
|
"from_title": "Präzision",
|
||||||
|
"to_title": "One Leg Squat",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
off_topic = [
|
||||||
|
{
|
||||||
|
"step_index": 2,
|
||||||
|
"exercise_id": 3,
|
||||||
|
"title": "One Leg Squat",
|
||||||
|
"expected_phase": "vertiefung",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
specs = collect_gap_fill_specs(
|
||||||
|
steps=steps,
|
||||||
|
unfilled_gaps=unfilled,
|
||||||
|
off_topic_steps=off_topic,
|
||||||
|
llm_specs=[],
|
||||||
|
brief=brief,
|
||||||
|
goal_query="Mae Geri Perfektion",
|
||||||
|
)
|
||||||
|
sources = {s["source"] for s in specs}
|
||||||
|
assert "unfilled_gap" in sources
|
||||||
|
assert "off_topic" in sources
|
||||||
|
off = next(s for s in specs if s["source"] == "off_topic")
|
||||||
|
assert off["replace_step_index"] == 2
|
||||||
|
assert off["insert_after_index"] == 1
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.189"
|
APP_VERSION = "0.8.190"
|
||||||
BUILD_DATE = "2026-05-23"
|
BUILD_DATE = "2026-05-23"
|
||||||
DB_SCHEMA_VERSION = "20260531074"
|
DB_SCHEMA_VERSION = "20260531077"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||||
"planning_exercise_suggest": "0.15.2", # Pfad-Gate: soft penalty + gestufter Fallback
|
"planning_exercise_suggest": "0.16.0", # E3: gap_fill_offers, Off-Topic, QA→KI-Pipeline
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -44,6 +44,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.190",
|
||||||
|
"date": "2026-05-23",
|
||||||
|
"changes": [
|
||||||
|
"Planungs-KI Phase E3: gap_fill_offers nach LLM-QS — Lücken, Off-Topic, QS-Neuanlage.",
|
||||||
|
"Pfad-Builder UI: „Fehlende Schritte — mit KI anlegen“ + ExerciseAiQuickCreateModal.",
|
||||||
|
"Schwache Bibliotheks-Brücken ablehnen; Migration 077 Pfad-QA suggested_new_exercises.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.189",
|
"version": "0.8.189",
|
||||||
"date": "2026-05-23",
|
"date": "2026-05-23",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPa
|
||||||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||||
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
||||||
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
|
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
|
||||||
|
const AdminUserContentPage = lazy(() => import('./pages/AdminUserContentPage'))
|
||||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||||
|
|
||||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||||
|
|
@ -327,6 +328,14 @@ const appRouter = createBrowserRouter([
|
||||||
</PlatformAdminRoute>
|
</PlatformAdminRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/user-content',
|
||||||
|
element: (
|
||||||
|
<PlatformAdminRoute>
|
||||||
|
<AdminUserContentPage />
|
||||||
|
</PlatformAdminRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react'
|
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||||
|
|
@ -8,6 +8,7 @@ export default function AdminPageNav() {
|
||||||
const pages = [
|
const pages = [
|
||||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||||
|
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
|
||||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Planungs-KI Phase C3: Ziel → Übungspfad vorschlagen → in Progressionsgraph speichern.
|
* Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern.
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
||||||
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||||
|
import {
|
||||||
|
aiPreviewToQuickCreateDraft,
|
||||||
|
buildQuickCreateAiPreview,
|
||||||
|
buildQuickCreateExercisePayloadFromDraft,
|
||||||
|
} from '../utils/exerciseAiQuickCreate'
|
||||||
|
|
||||||
function emptyPathStep() {
|
function emptyPathStep() {
|
||||||
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
|
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
|
||||||
|
|
@ -27,9 +34,32 @@ function mapApiStepToRow(step) {
|
||||||
isAiProposal,
|
isAiProposal,
|
||||||
aiSuggestion: step?.ai_suggestion || null,
|
aiSuggestion: step?.ai_suggestion || null,
|
||||||
semanticScore: step?.semantic_score,
|
semanticScore: step?.semantic_score,
|
||||||
|
isOffTopic: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapCreatedExerciseToRow(ex, offer) {
|
||||||
|
return {
|
||||||
|
exerciseId: Number(ex.id),
|
||||||
|
proposalKey: null,
|
||||||
|
exerciseTitle: (ex.title || offer?.title_hint || '').trim() || `Übung #${ex.id}`,
|
||||||
|
variantId: null,
|
||||||
|
variants: [],
|
||||||
|
reasons: ['Neu angelegt zur Schließung einer Pfad-Lücke'],
|
||||||
|
isBridge: true,
|
||||||
|
isAiProposal: false,
|
||||||
|
aiSuggestion: null,
|
||||||
|
semanticScore: null,
|
||||||
|
isOffTopic: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OFFER_SOURCE_LABELS = {
|
||||||
|
unfilled_gap: 'Lücke',
|
||||||
|
off_topic: 'Themenfremd',
|
||||||
|
llm_suggested: 'QS-Empfehlung',
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExerciseProgressionPathBuilder({
|
export default function ExerciseProgressionPathBuilder({
|
||||||
graphId,
|
graphId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
|
@ -45,6 +75,32 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
const [semanticBrief, setSemanticBrief] = useState(null)
|
const [semanticBrief, setSemanticBrief] = useState(null)
|
||||||
const [pathQa, setPathQa] = useState(null)
|
const [pathQa, setPathQa] = useState(null)
|
||||||
const [pathSteps, setPathSteps] = useState([])
|
const [pathSteps, setPathSteps] = useState([])
|
||||||
|
const [gapFillOffers, setGapFillOffers] = useState([])
|
||||||
|
const [focusAreas, setFocusAreas] = useState([])
|
||||||
|
|
||||||
|
const [quickCreateOpen, setQuickCreateOpen] = useState(false)
|
||||||
|
const [activeOffer, setActiveOffer] = useState(null)
|
||||||
|
const [quickTitle, setQuickTitle] = useState('')
|
||||||
|
const [quickSketch, setQuickSketch] = useState('')
|
||||||
|
const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
|
||||||
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||||
|
const [quickSaving, setQuickSaving] = useState(false)
|
||||||
|
const [quickAiError, setQuickAiError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.listFocusAreas({ status: 'active' })
|
||||||
|
.then((rows) => {
|
||||||
|
if (!cancelled) setFocusAreas(Array.isArray(rows) ? rows : [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFocusAreas([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const patchStep = useCallback((idx, patch) => {
|
const patchStep = useCallback((idx, patch) => {
|
||||||
setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
||||||
|
|
@ -66,6 +122,139 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const applyOffTopicFlags = (rows, qa) => {
|
||||||
|
const off = Array.isArray(qa?.off_topic_steps) ? qa.off_topic_steps : []
|
||||||
|
const indices = new Set(off.map((o) => Number(o.step_index)).filter(Number.isFinite))
|
||||||
|
return rows.map((row, idx) => ({ ...row, isOffTopic: indices.has(idx) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertExerciseFromOffer = useCallback((created, offer) => {
|
||||||
|
const row = mapCreatedExerciseToRow(created, offer)
|
||||||
|
setPathSteps((prev) => {
|
||||||
|
let next = [...prev]
|
||||||
|
const afterIdx = Number(offer?.insert_after_index)
|
||||||
|
const replaceIdx =
|
||||||
|
offer?.replace_step_index != null ? Number(offer.replace_step_index) : null
|
||||||
|
|
||||||
|
if (Number.isFinite(replaceIdx) && replaceIdx >= 0 && replaceIdx < next.length) {
|
||||||
|
next.splice(replaceIdx, 1, row)
|
||||||
|
} else if (Number.isFinite(afterIdx) && afterIdx >= 0 && afterIdx < next.length) {
|
||||||
|
next.splice(afterIdx + 1, 0, row)
|
||||||
|
} else {
|
||||||
|
next.push(row)
|
||||||
|
}
|
||||||
|
return applyOffTopicFlags(next, pathQa)
|
||||||
|
})
|
||||||
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
||||||
|
}, [pathQa])
|
||||||
|
|
||||||
|
const closeQuickCreate = () => {
|
||||||
|
if (quickSaving) return
|
||||||
|
setQuickCreateOpen(false)
|
||||||
|
setActiveOffer(null)
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
setQuickAiError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openOfferQuickCreate = (offer) => {
|
||||||
|
setActiveOffer(offer)
|
||||||
|
setQuickTitle((offer?.title_hint || '').trim())
|
||||||
|
setQuickSketch((offer?.sketch || '').trim())
|
||||||
|
setQuickFocusAreaId('')
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
setQuickAiError('')
|
||||||
|
|
||||||
|
if (offer?.has_ai_payload && offer?.ai_suggestion) {
|
||||||
|
const preview = buildQuickCreateAiPreview(offer.ai_suggestion, {
|
||||||
|
sketchPlain: (offer?.sketch || '').trim(),
|
||||||
|
})
|
||||||
|
if (preview.hasSummaryProposal || preview.hasSkillChoices || preview.hasInstructionChoices) {
|
||||||
|
const focusId = focusAreas[0]?.id ? String(focusAreas[0].id) : ''
|
||||||
|
setQuickFocusAreaId(focusId)
|
||||||
|
setQuickCreateDraft(
|
||||||
|
aiPreviewToQuickCreateDraft(preview, {
|
||||||
|
title: (offer?.title_hint || '').trim(),
|
||||||
|
focusAreaId: focusId ? Number(focusId) : '',
|
||||||
|
sketchPlain: (offer?.sketch || '').trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setQuickCreateOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setQuickCreateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runQuickCreateAiSuggest = async () => {
|
||||||
|
const title = (quickTitle || '').trim()
|
||||||
|
if (title.length < 3) {
|
||||||
|
alert('Titel: mindestens 3 Zeichen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sketch = (quickSketch || '').trim()
|
||||||
|
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
||||||
|
if (!Number.isFinite(focusId) || focusId < 1) {
|
||||||
|
alert('Bitte einen Fokusbereich wählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
|
||||||
|
const focusHint = (focusRow?.name || '').trim()
|
||||||
|
|
||||||
|
setQuickAiError('')
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
setQuickSaving(true)
|
||||||
|
try {
|
||||||
|
const aiRes = await api.suggestExerciseAi({
|
||||||
|
title,
|
||||||
|
goal: sketch || undefined,
|
||||||
|
execution: '',
|
||||||
|
preparation: '',
|
||||||
|
trainer_notes: '',
|
||||||
|
focus_area_hint: focusHint || undefined,
|
||||||
|
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
||||||
|
include_summary: true,
|
||||||
|
include_skills: true,
|
||||||
|
include_instructions: true,
|
||||||
|
})
|
||||||
|
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
|
||||||
|
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
||||||
|
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
||||||
|
}
|
||||||
|
setQuickCreateDraft(
|
||||||
|
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
||||||
|
)
|
||||||
|
setQuickCreateOpen(false)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
const msg = e?.message || String(e)
|
||||||
|
setQuickAiError(msg)
|
||||||
|
alert(msg || 'KI-Vorschlag fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setQuickSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyQuickCreateDraft = async () => {
|
||||||
|
if (!quickCreateDraft || !activeOffer) return
|
||||||
|
setQuickSaving(true)
|
||||||
|
setQuickAiError('')
|
||||||
|
try {
|
||||||
|
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
||||||
|
const created = await api.createExercise(payload)
|
||||||
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
|
insertExerciseFromOffer(created, activeOffer)
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
setActiveOffer(null)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
const msg = e?.message || String(e)
|
||||||
|
setQuickAiError(msg)
|
||||||
|
alert(msg || 'Übung konnte nicht angelegt werden')
|
||||||
|
} finally {
|
||||||
|
setQuickSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const suggestPath = async () => {
|
const suggestPath = async () => {
|
||||||
const q = (goalQuery || '').trim()
|
const q = (goalQuery || '').trim()
|
||||||
if (q.length < 3) {
|
if (q.length < 3) {
|
||||||
|
|
@ -85,16 +274,29 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
include_llm_intent: true,
|
include_llm_intent: true,
|
||||||
include_path_qa: true,
|
include_path_qa: true,
|
||||||
include_llm_path_qa: true,
|
include_llm_path_qa: true,
|
||||||
|
include_path_reorder: true,
|
||||||
|
include_ai_gap_fill: true,
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
})
|
})
|
||||||
const rows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
|
const qa = res?.path_qa || null
|
||||||
|
const rows = applyOffTopicFlags(
|
||||||
|
(Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow),
|
||||||
|
qa,
|
||||||
|
)
|
||||||
if (rows.length < 2) {
|
if (rows.length < 2) {
|
||||||
throw new Error('Zu wenig Schritte im Vorschlag.')
|
throw new Error('Zu wenig Schritte im Vorschlag.')
|
||||||
}
|
}
|
||||||
setPathSteps(rows)
|
setPathSteps(rows)
|
||||||
setTargetSummary(res?.target_profile_summary || null)
|
setTargetSummary(res?.target_profile_summary || null)
|
||||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
setPathQa(res?.path_qa || null)
|
setPathQa(qa)
|
||||||
|
setGapFillOffers(
|
||||||
|
Array.isArray(res?.gap_fill_offers)
|
||||||
|
? res.gap_fill_offers
|
||||||
|
: Array.isArray(qa?.gap_fill_offers)
|
||||||
|
? qa.gap_fill_offers
|
||||||
|
: [],
|
||||||
|
)
|
||||||
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
|
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -103,6 +305,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setTargetSummary(null)
|
setTargetSummary(null)
|
||||||
setSemanticBrief(null)
|
setSemanticBrief(null)
|
||||||
setPathQa(null)
|
setPathQa(null)
|
||||||
|
setGapFillOffers([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +322,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
alert(
|
alert(
|
||||||
skippedAi > 0
|
skippedAi > 0
|
||||||
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
|
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
|
||||||
: 'Mindestens zwei Schritte mit Übung nötig.'
|
: 'Mindestens zwei Schritte mit Übung nötig.',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +348,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setTargetSummary(null)
|
setTargetSummary(null)
|
||||||
setSemanticBrief(null)
|
setSemanticBrief(null)
|
||||||
setPathQa(null)
|
setPathQa(null)
|
||||||
|
setGapFillOffers([])
|
||||||
if (typeof onSaved === 'function') await onSaved()
|
if (typeof onSaved === 'function') await onSaved()
|
||||||
const msg =
|
const msg =
|
||||||
skippedAi > 0
|
skippedAi > 0
|
||||||
|
|
@ -170,7 +374,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||||
Ziel in Freitext formulieren — die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor,
|
Ziel in Freitext formulieren — die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor,
|
||||||
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Nach Review in den Graph speichern.
|
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Fehlende Schritte können mit KI als Übung angelegt werden.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
||||||
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
|
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
|
||||||
|
|
@ -179,7 +383,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={goalQuery}
|
value={goalQuery}
|
||||||
onChange={(e) => setGoalQuery(e.target.value)}
|
onChange={(e) => setGoalQuery(e.target.value)}
|
||||||
placeholder="z. B. sichere Reaktion im Partnertraining aufbauen …"
|
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
|
||||||
disabled={disabled || loading || saving}
|
disabled={disabled || loading || saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -263,9 +467,9 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
|
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{Number(pathQa.ai_proposal_count) > 0 ? (
|
{Number(pathQa.off_topic_count) > 0 ? (
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
|
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
||||||
{pathQa.ai_proposal_count} KI-Neuanlage-Vorschlag/Vorschläge — vor dem Speichern als Übung anlegen.
|
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{pathQa.reorder_applied ? (
|
{pathQa.reorder_applied ? (
|
||||||
|
|
@ -276,12 +480,64 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{Array.isArray(targetSummary?.top_skills) &&
|
</div>
|
||||||
targetSummary.top_skills.slice(0, 2).map((sk) => (
|
) : null}
|
||||||
<span key={sk.skill_id} className="exercise-tag">
|
|
||||||
{sk.name}
|
{gapFillOffers.length > 0 ? (
|
||||||
</span>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--accent) 40%, var(--border))',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 5%, var(--surface))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: '13px' }}>Fehlende Schritte — mit KI anlegen</strong>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
|
||||||
|
Die QS hat Lücken erkannt. Vorschlag prüfen, als Übung anlegen und in den Pfad einfügen.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{gapFillOffers.map((offer) => (
|
||||||
|
<div
|
||||||
|
key={offer.offer_id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
|
||||||
|
{OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
|
||||||
|
{offer.phase ? ` · ${offer.phase}` : ''}
|
||||||
|
</span>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600 }}>{offer.title_hint}</div>
|
||||||
|
{offer.rationale ? (
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '4px 0 0' }}>{offer.rationale}</p>
|
||||||
|
) : null}
|
||||||
|
{offer.from_title && offer.to_title ? (
|
||||||
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
|
||||||
|
Zwischen „{offer.from_title}“ und „{offer.to_title}“
|
||||||
|
{offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ fontSize: '12px', flexShrink: 0 }}
|
||||||
|
disabled={quickSaving}
|
||||||
|
onClick={() => openOfferQuickCreate(offer)}
|
||||||
|
>
|
||||||
|
Mit KI anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -290,7 +546,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
<div style={{ marginTop: '14px' }}>
|
<div style={{ marginTop: '14px' }}>
|
||||||
{pathSteps.map((step, idx) => (
|
{pathSteps.map((step, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${step.exerciseId}-${idx}`}
|
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
|
@ -299,13 +555,19 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
paddingBottom: '12px',
|
paddingBottom: '12px',
|
||||||
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
|
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
|
||||||
|
background: step.isOffTopic
|
||||||
|
? 'color-mix(in srgb, var(--danger) 6%, transparent)'
|
||||||
|
: undefined,
|
||||||
|
borderRadius: step.isOffTopic ? '8px' : undefined,
|
||||||
|
padding: step.isOffTopic ? '8px' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label">
|
<label className="form-label">
|
||||||
Schritt {idx + 1}
|
Schritt {idx + 1}
|
||||||
|
{step.isOffTopic ? ' (themenfremd)' : ''}
|
||||||
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
|
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
|
||||||
{!step.isAiProposal && idx === 0 ? ' (Einstieg)' : ''}
|
{!step.isAiProposal && !step.isOffTopic && idx === 0 ? ' (Einstieg)' : ''}
|
||||||
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
|
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
|
||||||
</label>
|
</label>
|
||||||
<div style={{ fontSize: '13px' }}>
|
<div style={{ fontSize: '13px' }}>
|
||||||
|
|
@ -397,6 +659,9 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPathSteps([])
|
setPathSteps([])
|
||||||
setTargetSummary(null)
|
setTargetSummary(null)
|
||||||
|
setSemanticBrief(null)
|
||||||
|
setPathQa(null)
|
||||||
|
setGapFillOffers([])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Vorschlag verwerfen
|
Vorschlag verwerfen
|
||||||
|
|
@ -404,6 +669,40 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ExerciseAiQuickCreateModal
|
||||||
|
open={quickCreateOpen}
|
||||||
|
onClose={closeQuickCreate}
|
||||||
|
searchLabel={activeOffer?.title_hint || goalQuery}
|
||||||
|
title={quickTitle}
|
||||||
|
onTitleChange={setQuickTitle}
|
||||||
|
sketch={quickSketch}
|
||||||
|
onSketchChange={setQuickSketch}
|
||||||
|
focusAreaId={quickFocusAreaId}
|
||||||
|
onFocusAreaChange={setQuickFocusAreaId}
|
||||||
|
focusAreas={focusAreas}
|
||||||
|
catalogsReady={focusAreas.length > 0}
|
||||||
|
busy={quickSaving}
|
||||||
|
error={quickAiError}
|
||||||
|
onRunAi={runQuickCreateAiSuggest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExerciseAiSuggestPreviewModal
|
||||||
|
draft={quickCreateDraft}
|
||||||
|
onDraftChange={setQuickCreateDraft}
|
||||||
|
onDiscard={() => {
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
if (activeOffer) setQuickCreateOpen(true)
|
||||||
|
}}
|
||||||
|
onApply={applyQuickCreateDraft}
|
||||||
|
focusAreas={focusAreas}
|
||||||
|
skillsCatalog={[]}
|
||||||
|
dialogTitle="Pfad-Lücke — KI-Entwurf bearbeiten"
|
||||||
|
hint="Texte anpassen, dann als Übung speichern und in den Pfad einfügen."
|
||||||
|
applyLabel={quickSaving ? 'Wird angelegt …' : 'Anlegen und in Pfad einfügen'}
|
||||||
|
applyDisabled={quickSaving}
|
||||||
|
zIndex={2100}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
681
frontend/src/pages/AdminUserContentPage.jsx
Normal file
681
frontend/src/pages/AdminUserContentPage.jsx
Normal file
|
|
@ -0,0 +1,681 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import AdminPageNav from '../components/AdminPageNav'
|
||||||
|
|
||||||
|
const VISIBILITY_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'Alle Sichtbarkeiten' },
|
||||||
|
{ value: 'private', label: 'Privat' },
|
||||||
|
{ value: 'club', label: 'Verein' },
|
||||||
|
{ value: 'official', label: 'Offiziell' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const VISIBILITY_LABEL = {
|
||||||
|
private: 'Privat',
|
||||||
|
club: 'Verein',
|
||||||
|
official: 'Offiziell',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
draft: 'Entwurf',
|
||||||
|
in_review: 'In Prüfung',
|
||||||
|
approved: 'Freigegeben',
|
||||||
|
archived: 'Archiviert',
|
||||||
|
active: 'Aktiv',
|
||||||
|
legacy_unreviewed: 'Rechte ungeprüft',
|
||||||
|
declared: 'Rechte erklärt',
|
||||||
|
blocked: 'Gesperrt',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIFECYCLE_LABELS = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
trash_soft: 'Papierkorb (soft)',
|
||||||
|
trash_hidden: 'Papierkorb (hidden)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '—'
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentLink(item) {
|
||||||
|
const id = item.id
|
||||||
|
switch (item.content_type) {
|
||||||
|
case 'exercise':
|
||||||
|
return `/exercises/${id}`
|
||||||
|
case 'training_module':
|
||||||
|
return `/planning/training-modules/${id}`
|
||||||
|
case 'framework_program':
|
||||||
|
return `/planning/framework-programs/${id}`
|
||||||
|
case 'plan_template':
|
||||||
|
return `/planning/plan-templates/${id}`
|
||||||
|
case 'maturity_model':
|
||||||
|
return '/admin/maturity-models'
|
||||||
|
case 'media_asset':
|
||||||
|
return '/media'
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOptionsForType(meta, contentType) {
|
||||||
|
const t = meta?.content_types?.find((x) => x.id === contentType)
|
||||||
|
return (t?.status_values || []).map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: STATUS_LABELS[v] || v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditModal({ open, item, meta, onClose, onSaved }) {
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [visibility, setVisibility] = useState('')
|
||||||
|
const [lifecycle, setLifecycle] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item) return
|
||||||
|
setStatus(item.status || '')
|
||||||
|
setVisibility(item.visibility || '')
|
||||||
|
setLifecycle(item.extra_status || 'active')
|
||||||
|
setError('')
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
if (!open || !item) return null
|
||||||
|
|
||||||
|
const typeMeta = meta?.content_types?.find((x) => x.id === item.content_type)
|
||||||
|
const statusOpts = statusOptionsForType(meta, item.content_type)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const body = {}
|
||||||
|
if (typeMeta?.has_status && status && status !== item.status) body.status = status
|
||||||
|
if (typeMeta?.has_visibility && visibility && visibility !== item.visibility) {
|
||||||
|
body.visibility = visibility
|
||||||
|
}
|
||||||
|
if (item.content_type === 'media_asset' && lifecycle && lifecycle !== item.extra_status) {
|
||||||
|
body.lifecycle_state = lifecycle
|
||||||
|
}
|
||||||
|
if (!Object.keys(body).length) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await api.patchAdminUserContentItem(item.content_type, item.id, body)
|
||||||
|
await onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="card modal-card" style={{ maxWidth: 480, width: '100%' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Inhalt bearbeiten</h2>
|
||||||
|
<p className="text-muted" style={{ marginTop: 0 }}>
|
||||||
|
{item.type_label} · #{item.id}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontWeight: 600 }}>{item.title || '—'}</p>
|
||||||
|
|
||||||
|
{typeMeta?.has_status ? (
|
||||||
|
<div className="form-row" style={{ marginTop: 16 }}>
|
||||||
|
<label className="form-label" htmlFor="uc-status">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="uc-status"
|
||||||
|
className="form-input"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
{statusOpts.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{typeMeta?.has_visibility ? (
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label" htmlFor="uc-vis">
|
||||||
|
Sichtbarkeit
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="uc-vis"
|
||||||
|
className="form-input"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value)}
|
||||||
|
>
|
||||||
|
{VISIBILITY_OPTIONS.filter((o) => o.value !== 'all').map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.content_type === 'media_asset' ? (
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label" htmlFor="uc-lc">
|
||||||
|
Lifecycle
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="uc-lc"
|
||||||
|
className="form-input"
|
||||||
|
value={lifecycle}
|
||||||
|
onChange={(e) => setLifecycle(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(LIFECYCLE_LABELS).map(([v, label]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p style={{ color: 'var(--danger)', marginTop: 12 }} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'flex-end' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
|
||||||
|
{saving ? 'Speichern…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminUserContentPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
|
|
||||||
|
const [meta, setMeta] = useState(null)
|
||||||
|
const [userSummary, setUserSummary] = useState([])
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [itemsLoading, setItemsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [profileId, setProfileId] = useState('')
|
||||||
|
const [contentType, setContentType] = useState('all')
|
||||||
|
const [visibility, setVisibility] = useState('all')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const limit = 50
|
||||||
|
|
||||||
|
const [editItem, setEditItem] = useState(null)
|
||||||
|
|
||||||
|
const contentTypeOptions = useMemo(() => {
|
||||||
|
const base = [{ value: 'all', label: 'Alle Typen' }]
|
||||||
|
for (const t of meta?.content_types || []) {
|
||||||
|
base.push({ value: t.id, label: t.label })
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}, [meta])
|
||||||
|
|
||||||
|
const statusFilterOptions = useMemo(() => {
|
||||||
|
if (contentType === 'all') {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'Beliebiger Status' },
|
||||||
|
{ value: 'draft', label: STATUS_LABELS.draft },
|
||||||
|
{ value: 'in_review', label: STATUS_LABELS.in_review },
|
||||||
|
{ value: 'approved', label: STATUS_LABELS.approved },
|
||||||
|
{ value: 'archived', label: STATUS_LABELS.archived },
|
||||||
|
{ value: 'active', label: STATUS_LABELS.active },
|
||||||
|
{ value: 'legacy_unreviewed', label: STATUS_LABELS.legacy_unreviewed },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'Beliebiger Status' },
|
||||||
|
...statusOptionsForType(meta, contentType),
|
||||||
|
]
|
||||||
|
}, [contentType, meta])
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
const [m, s] = await Promise.all([api.getAdminUserContentMeta(), api.listAdminUserContentSummary()])
|
||||||
|
setMeta(m)
|
||||||
|
setUserSummary(Array.isArray(s) ? s : [])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadItems = useCallback(async (forcedOffset) => {
|
||||||
|
setItemsLoading(true)
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
content_type: contentType,
|
||||||
|
visibility,
|
||||||
|
limit,
|
||||||
|
offset: forcedOffset ?? offset,
|
||||||
|
}
|
||||||
|
if (profileId) params.profile_id = Number(profileId)
|
||||||
|
if (status) params.status = status
|
||||||
|
if (search.trim()) params.search = search.trim()
|
||||||
|
const res = await api.listAdminUserContentItems(params)
|
||||||
|
setItems(Array.isArray(res?.items) ? res.items : [])
|
||||||
|
setTotal(Number(res?.total) || 0)
|
||||||
|
} finally {
|
||||||
|
setItemsLoading(false)
|
||||||
|
}
|
||||||
|
}, [contentType, visibility, status, search, profileId, offset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperadmin) return
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await loadSummary()
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isSuperadmin, loadSummary])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperadmin) return
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
await loadItems()
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e.message || String(e))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isSuperadmin, loadItems])
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
setOffset(0)
|
||||||
|
loadItems(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (item) => {
|
||||||
|
const label = item.title || `${item.type_label} #${item.id}`
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`„${label}" wirklich endgültig löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteAdminUserContentItem(item.content_type, item.id)
|
||||||
|
await Promise.all([loadItems(), loadSummary()])
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ paddingBottom: 96 }}>
|
||||||
|
<AdminPageNav />
|
||||||
|
<header style={{ marginBottom: 20 }}>
|
||||||
|
<h1 style={{ margin: '0 0 8px' }}>Nutzer-Inhalte</h1>
|
||||||
|
<p className="text-muted" style={{ margin: 0 }}>
|
||||||
|
Aktivitäten aller Nutzer einsehen — inklusive privater Inhalte. Status setzen oder Inhalte
|
||||||
|
löschen (nur Superadmin).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
|
||||||
|
<p style={{ margin: 0, color: 'var(--danger)' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="spinner" aria-label="Laden" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="card" style={{ marginBottom: 16 }}>
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Aktivität je Nutzer</h2>
|
||||||
|
{userSummary.length === 0 ? (
|
||||||
|
<p className="text-muted" style={{ margin: 0 }}>
|
||||||
|
Noch keine nutzerangelegten Inhalte.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="data-table" style={{ width: '100%', minWidth: 640 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nutzer</th>
|
||||||
|
<th>Gesamt</th>
|
||||||
|
{(meta?.content_types || []).map((t) => (
|
||||||
|
<th key={t.id}>{t.label}</th>
|
||||||
|
))}
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{userSummary.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>
|
||||||
|
<div style={{ fontWeight: 600 }}>{u.name || `Profil #${u.id}`}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: '0.85rem' }}>
|
||||||
|
{u.email || '—'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{u.total_count}</strong>
|
||||||
|
</td>
|
||||||
|
{(meta?.content_types || []).map((t) => (
|
||||||
|
<td key={t.id}>{u.counts_by_type?.[t.id] ?? 0}</td>
|
||||||
|
))}
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '0.85rem', padding: '4px 10px' }}
|
||||||
|
onClick={() => {
|
||||||
|
setProfileId(String(u.id))
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Filtern
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Inhalte</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="uc-user">
|
||||||
|
Nutzer
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="uc-user"
|
||||||
|
className="form-input"
|
||||||
|
value={profileId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProfileId(e.target.value)
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Alle Nutzer</option>
|
||||||
|
{userSummary.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name || u.email || `#${u.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="uc-type">
|
||||||
|
Typ
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="uc-type"
|
||||||
|
className="form-input"
|
||||||
|
value={contentType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setContentType(e.target.value)
|
||||||
|
setStatus('')
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contentTypeOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="uc-vis-filter">
|
||||||
|
Sichtbarkeit
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="uc-vis-filter"
|
||||||
|
className="form-input"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVisibility(e.target.value)
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VISIBILITY_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="uc-status-filter">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="uc-status-filter"
|
||||||
|
className="form-input"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value)
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusFilterOptions.map((o) => (
|
||||||
|
<option key={o.value || '_any'} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label className="form-label" htmlFor="uc-search">
|
||||||
|
Suche (Titel)
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
id="uc-search"
|
||||||
|
className="form-input"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||||
|
placeholder="Titel oder Dateiname…"
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={applyFilters}>
|
||||||
|
Suchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemsLoading ? (
|
||||||
|
<div className="spinner" aria-label="Inhalte laden" />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-muted">Keine Inhalte für die aktuellen Filter.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="data-table" style={{ width: '100%', minWidth: 900 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Nutzer</th>
|
||||||
|
<th>Sichtbarkeit</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktualisiert</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const href = contentLink(item)
|
||||||
|
return (
|
||||||
|
<tr key={`${item.content_type}-${item.id}`}>
|
||||||
|
<td>{item.type_label}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ fontWeight: 600, maxWidth: 280 }}>
|
||||||
|
{href ? (
|
||||||
|
<Link to={href}>{item.title || '—'}</Link>
|
||||||
|
) : (
|
||||||
|
item.title || '—'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||||
|
#{item.id}
|
||||||
|
{item.club_name ? ` · ${item.club_name}` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{item.profile_name || '—'}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||||
|
{item.profile_email || (item.profile_id ? `#${item.profile_id}` : '—')}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{item.visibility ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
item.visibility === 'private' ? 'var(--text2)' : 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VISIBILITY_LABEL[item.visibility] || item.visibility}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{item.status ? STATUS_LABELS[item.status] || item.status : '—'}
|
||||||
|
{item.extra_status && item.extra_status !== 'active' ? (
|
||||||
|
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||||
|
{LIFECYCLE_LABELS[item.extra_status] || item.extra_status}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(item.updated_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '0.8rem', padding: '4px 8px' }}
|
||||||
|
onClick={() => setEditItem(item)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '4px 8px',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
}}
|
||||||
|
onClick={() => handleDelete(item)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > limit ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-muted" style={{ fontSize: '0.9rem' }}>
|
||||||
|
{total} Einträge · Seite {Math.floor(offset / limit) + 1} von{' '}
|
||||||
|
{Math.ceil(total / limit)}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={offset <= 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={offset + limit >= total}
|
||||||
|
onClick={() => setOffset(offset + limit)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditModal
|
||||||
|
open={!!editItem}
|
||||||
|
item={editItem}
|
||||||
|
meta={meta}
|
||||||
|
onClose={() => setEditItem(null)}
|
||||||
|
onSaved={async () => {
|
||||||
|
await Promise.all([loadItems(), loadSummary()])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,39 @@ export async function listAdminUsers() {
|
||||||
return request('/api/admin/users')
|
return request('/api/admin/users')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Superadmin: Metadaten zu nutzerangelegten Inhaltstypen. */
|
||||||
|
export async function getAdminUserContentMeta() {
|
||||||
|
return request('/api/admin/user-content/meta')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Superadmin: Aktivitätsübersicht je Nutzer (Anzahl Inhalte). */
|
||||||
|
export async function listAdminUserContentSummary() {
|
||||||
|
return request('/api/admin/user-content/users-summary')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Superadmin: Inhalte aller Nutzer (inkl. privat) — filterbar. */
|
||||||
|
export async function listAdminUserContentItems(params = {}) {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
|
||||||
|
}
|
||||||
|
const q = qs.toString()
|
||||||
|
return request(`/api/admin/user-content/items${q ? `?${q}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Superadmin: Status/Sichtbarkeit eines Inhalts setzen. */
|
||||||
|
export async function patchAdminUserContentItem(contentType, itemId, body) {
|
||||||
|
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Superadmin: Inhalt löschen. */
|
||||||
|
export async function deleteAdminUserContentItem(contentType, itemId) {
|
||||||
|
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
|
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
|
||||||
export async function getPlatformMediaStorage() {
|
export async function getPlatformMediaStorage() {
|
||||||
return request('/api/admin/platform-media-storage')
|
return request('/api/admin/platform-media-storage')
|
||||||
|
|
@ -819,6 +852,11 @@ export const api = {
|
||||||
getCurrentProfile,
|
getCurrentProfile,
|
||||||
listProfiles,
|
listProfiles,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
|
getAdminUserContentMeta,
|
||||||
|
listAdminUserContentSummary,
|
||||||
|
listAdminUserContentItems,
|
||||||
|
patchAdminUserContentItem,
|
||||||
|
deleteAdminUserContentItem,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
managementPasswordReset,
|
managementPasswordReset,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user