From dde45b29db96d982dd54d7b81ae83f969ec21ec2 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 25 Apr 2026 22:32:45 +0200 Subject: [PATCH] Restructure: Move 43 files into 8 domain packages (backend-lehrer) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend-lehrer/abitur/__init__.py | 1 + backend-lehrer/abitur/api.py | 413 +++++++++++++++ backend-lehrer/abitur/models.py | 327 ++++++++++++ backend-lehrer/abitur/recognition.py | 124 +++++ backend-lehrer/abitur_docs_api.py | 417 +-------------- backend-lehrer/abitur_docs_models.py | 331 +----------- backend-lehrer/abitur_docs_recognition.py | 128 +---- backend-lehrer/certificates_api.py | 344 +----------- backend-lehrer/certificates_models.py | 188 +------ backend-lehrer/correction/__init__.py | 1 + backend-lehrer/correction/api.py | 23 + backend-lehrer/correction/endpoints.py | 474 +++++++++++++++++ backend-lehrer/correction/helpers.py | 134 +++++ backend-lehrer/correction/models.py | 111 ++++ backend-lehrer/correction_api.py | 27 +- backend-lehrer/correction_endpoints.py | 478 +---------------- backend-lehrer/correction_helpers.py | 138 +---- backend-lehrer/correction_models.py | 115 +--- backend-lehrer/dashboard/__init__.py | 1 + backend-lehrer/dashboard/analytics.py | 267 ++++++++++ backend-lehrer/dashboard/api.py | 329 ++++++++++++ backend-lehrer/dashboard/models.py | 226 ++++++++ backend-lehrer/game/api.py | 46 ++ backend-lehrer/game/extended_routes.py | 189 +++++++ backend-lehrer/game/game_models.py | 322 +++++++++++ backend-lehrer/game/routes.py | 296 +++++++++++ backend-lehrer/game/session_routes.py | 395 ++++++++++++++ backend-lehrer/game_api.py | 50 +- backend-lehrer/game_extended_routes.py | 193 +------ backend-lehrer/game_models.py | 326 +----------- backend-lehrer/game_routes.py | 300 +---------- backend-lehrer/game_session_routes.py | 399 +------------- backend-lehrer/learning_units.py | 182 +------ backend-lehrer/learning_units_api.py | 380 +------------ backend-lehrer/letters/__init__.py | 1 + backend-lehrer/letters/api.py | 346 ++++++++++++ backend-lehrer/letters/certificates_api.py | 340 ++++++++++++ backend-lehrer/letters/certificates_models.py | 184 +++++++ backend-lehrer/letters/models.py | 195 +++++++ backend-lehrer/letters_api.py | 350 +----------- backend-lehrer/letters_models.py | 199 +------ backend-lehrer/messenger/__init__.py | 1 + backend-lehrer/messenger/api.py | 21 + backend-lehrer/messenger/contacts.py | 251 +++++++++ backend-lehrer/messenger/conversations.py | 405 ++++++++++++++ backend-lehrer/messenger/helpers.py | 105 ++++ backend-lehrer/messenger/models.py | 139 +++++ backend-lehrer/messenger_api.py | 25 +- backend-lehrer/messenger_contacts.py | 255 +-------- backend-lehrer/messenger_conversations.py | 409 +------------- backend-lehrer/messenger_helpers.py | 109 +--- backend-lehrer/messenger_models.py | 143 +---- backend-lehrer/recording/__init__.py | 1 + backend-lehrer/recording/api.py | 22 + backend-lehrer/recording/helpers.py | 57 ++ backend-lehrer/recording/minutes.py | 187 +++++++ backend-lehrer/recording/models.py | 98 ++++ backend-lehrer/recording/routes.py | 307 +++++++++++ backend-lehrer/recording/transcription.py | 250 +++++++++ backend-lehrer/recording_api.py | 26 +- backend-lehrer/recording_helpers.py | 61 +-- backend-lehrer/recording_minutes.py | 191 +------ backend-lehrer/recording_models.py | 102 +--- backend-lehrer/recording_routes.py | 311 +---------- backend-lehrer/recording_transcription.py | 254 +-------- backend-lehrer/teacher_dashboard_analytics.py | 271 +--------- backend-lehrer/teacher_dashboard_api.py | 333 +----------- backend-lehrer/teacher_dashboard_models.py | 230 +------- backend-lehrer/unit_analytics_api.py | 29 +- backend-lehrer/unit_analytics_export.py | 149 +----- backend-lehrer/unit_analytics_helpers.py | 101 +--- backend-lehrer/unit_analytics_models.py | 131 +---- backend-lehrer/unit_analytics_routes.py | 398 +------------- backend-lehrer/unit_api.py | 61 +-- backend-lehrer/unit_content_routes.py | 164 +----- backend-lehrer/unit_definition_routes.py | 305 +---------- backend-lehrer/unit_helpers.py | 208 +------- backend-lehrer/unit_models.py | 153 +----- backend-lehrer/unit_routes.py | 498 +----------------- backend-lehrer/units/__init__.py | 1 + backend-lehrer/units/analytics_api.py | 25 + backend-lehrer/units/analytics_export.py | 145 +++++ backend-lehrer/units/analytics_helpers.py | 97 ++++ backend-lehrer/units/analytics_models.py | 127 +++++ backend-lehrer/units/analytics_routes.py | 394 ++++++++++++++ backend-lehrer/units/api.py | 57 ++ backend-lehrer/units/content_routes.py | 160 ++++++ backend-lehrer/units/definition_routes.py | 301 +++++++++++ backend-lehrer/units/helpers.py | 204 +++++++ backend-lehrer/units/learning.py | 178 +++++++ backend-lehrer/units/learning_api.py | 376 +++++++++++++ backend-lehrer/units/models.py | 149 ++++++ backend-lehrer/units/routes.py | 494 +++++++++++++++++ 93 files changed, 9469 insertions(+), 9290 deletions(-) create mode 100644 backend-lehrer/abitur/__init__.py create mode 100644 backend-lehrer/abitur/api.py create mode 100644 backend-lehrer/abitur/models.py create mode 100644 backend-lehrer/abitur/recognition.py create mode 100644 backend-lehrer/correction/__init__.py create mode 100644 backend-lehrer/correction/api.py create mode 100644 backend-lehrer/correction/endpoints.py create mode 100644 backend-lehrer/correction/helpers.py create mode 100644 backend-lehrer/correction/models.py create mode 100644 backend-lehrer/dashboard/__init__.py create mode 100644 backend-lehrer/dashboard/analytics.py create mode 100644 backend-lehrer/dashboard/api.py create mode 100644 backend-lehrer/dashboard/models.py create mode 100644 backend-lehrer/game/api.py create mode 100644 backend-lehrer/game/extended_routes.py create mode 100644 backend-lehrer/game/game_models.py create mode 100644 backend-lehrer/game/routes.py create mode 100644 backend-lehrer/game/session_routes.py create mode 100644 backend-lehrer/letters/__init__.py create mode 100644 backend-lehrer/letters/api.py create mode 100644 backend-lehrer/letters/certificates_api.py create mode 100644 backend-lehrer/letters/certificates_models.py create mode 100644 backend-lehrer/letters/models.py create mode 100644 backend-lehrer/messenger/__init__.py create mode 100644 backend-lehrer/messenger/api.py create mode 100644 backend-lehrer/messenger/contacts.py create mode 100644 backend-lehrer/messenger/conversations.py create mode 100644 backend-lehrer/messenger/helpers.py create mode 100644 backend-lehrer/messenger/models.py create mode 100644 backend-lehrer/recording/__init__.py create mode 100644 backend-lehrer/recording/api.py create mode 100644 backend-lehrer/recording/helpers.py create mode 100644 backend-lehrer/recording/minutes.py create mode 100644 backend-lehrer/recording/models.py create mode 100644 backend-lehrer/recording/routes.py create mode 100644 backend-lehrer/recording/transcription.py create mode 100644 backend-lehrer/units/__init__.py create mode 100644 backend-lehrer/units/analytics_api.py create mode 100644 backend-lehrer/units/analytics_export.py create mode 100644 backend-lehrer/units/analytics_helpers.py create mode 100644 backend-lehrer/units/analytics_models.py create mode 100644 backend-lehrer/units/analytics_routes.py create mode 100644 backend-lehrer/units/api.py create mode 100644 backend-lehrer/units/content_routes.py create mode 100644 backend-lehrer/units/definition_routes.py create mode 100644 backend-lehrer/units/helpers.py create mode 100644 backend-lehrer/units/learning.py create mode 100644 backend-lehrer/units/learning_api.py create mode 100644 backend-lehrer/units/models.py create mode 100644 backend-lehrer/units/routes.py diff --git a/backend-lehrer/abitur/__init__.py b/backend-lehrer/abitur/__init__.py new file mode 100644 index 0000000..3cd7308 --- /dev/null +++ b/backend-lehrer/abitur/__init__.py @@ -0,0 +1 @@ +# abitur — Abitur document management (exam docs, recognition). diff --git a/backend-lehrer/abitur/api.py b/backend-lehrer/abitur/api.py new file mode 100644 index 0000000..fb2e94f --- /dev/null +++ b/backend-lehrer/abitur/api.py @@ -0,0 +1,413 @@ +""" +Abitur Document Store API - Verwaltung von Abitur-Aufgaben und Erwartungshorizonten. + +Unterstützt: +- Bundesland-spezifische Dokumente +- Fach, Jahr, Niveau (eA/gA), Aufgabennummer +- KI-basierte Dokumentenerkennung +- RAG-Integration mit Vector Store + +Dateinamen-Schema (NiBiS Niedersachsen): +- 2025_Deutsch_eA_I.pdf - Aufgabe +- 2025_Deutsch_eA_I_EWH.pdf - Erwartungshorizont +""" + +import logging +import uuid +import os +import zipfile +import tempfile +from datetime import datetime +from typing import List, Optional, Dict, Any +from pathlib import Path + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks +from fastapi.responses import FileResponse + +from .models import ( + Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus, + DokumentCreate, DokumentUpdate, DokumentResponse, ImportResult, + RecognitionResult, AbiturDokument, + FACH_LABELS, DOKUMENT_TYP_LABELS, + # Backwards-compatibility re-exports + AbiturFach, Anforderungsniveau, DocumentMetadata, AbiturDokumentCompat, +) +from .recognition import parse_nibis_filename, to_dokument_response + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/abitur-docs", + tags=["abitur-docs"], +) + +# Storage directory +DOCS_DIR = Path("/tmp/abitur-docs") +DOCS_DIR.mkdir(parents=True, exist_ok=True) + +# In-Memory Storage +_dokumente: Dict[str, AbiturDokument] = {} + +# Backwards-compatibility alias +documents_db = _dokumente + + +# ============================================================================ +# Private helper (kept local since it references module-level _dokumente) +# ============================================================================ + +def _to_dokument_response(doc: AbiturDokument) -> DokumentResponse: + return to_dokument_response(doc) + + +# ============================================================================ +# API Endpoints - Dokumente +# ============================================================================ + +@router.post("/upload", response_model=DokumentResponse) +async def upload_dokument( + file: UploadFile = File(...), + bundesland: Optional[Bundesland] = Form(None), + fach: Optional[Fach] = Form(None), + jahr: Optional[int] = Form(None), + niveau: Optional[Niveau] = Form(None), + typ: Optional[DokumentTyp] = Form(None), + aufgaben_nummer: Optional[str] = Form(None) +): + """Lädt ein einzelnes Dokument hoch.""" + if not file.filename: + raise HTTPException(status_code=400, detail="Kein Dateiname") + + recognition = parse_nibis_filename(file.filename) + + final_bundesland = bundesland or recognition.bundesland or Bundesland.NIEDERSACHSEN + final_fach = fach or recognition.fach + final_jahr = jahr or recognition.jahr or datetime.now().year + final_niveau = niveau or recognition.niveau or Niveau.EA + final_typ = typ or recognition.typ or DokumentTyp.AUFGABE + final_aufgabe = aufgaben_nummer or recognition.aufgaben_nummer + + if not final_fach: + raise HTTPException(status_code=400, detail="Fach konnte nicht erkannt werden") + + doc_id = str(uuid.uuid4()) + file_ext = Path(file.filename).suffix + safe_filename = f"{doc_id}{file_ext}" + file_path = DOCS_DIR / safe_filename + + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + now = datetime.utcnow() + dokument = AbiturDokument( + id=doc_id, dateiname=safe_filename, original_dateiname=file.filename, + bundesland=final_bundesland, fach=final_fach, jahr=final_jahr, + niveau=final_niveau, typ=final_typ, aufgaben_nummer=final_aufgabe, + status=VerarbeitungsStatus.RECOGNIZED if recognition.success else VerarbeitungsStatus.PENDING, + confidence=recognition.confidence, file_path=str(file_path), file_size=len(content), + indexed=False, vector_ids=[], created_at=now, updated_at=now + ) + _dokumente[doc_id] = dokument + logger.info(f"Uploaded document {doc_id}: {file.filename}") + return _to_dokument_response(dokument) + + +@router.post("/import-zip", response_model=ImportResult) +async def import_zip( + file: UploadFile = File(...), + bundesland: Bundesland = Form(Bundesland.NIEDERSACHSEN), + background_tasks: BackgroundTasks = None +): + """Importiert alle PDFs aus einer ZIP-Datei.""" + if not file.filename or not file.filename.endswith(".zip"): + raise HTTPException(status_code=400, detail="ZIP-Datei erforderlich") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + documents = [] + total = 0 + recognized = 0 + errors = 0 + + try: + with zipfile.ZipFile(tmp_path, 'r') as zip_ref: + for zip_info in zip_ref.infolist(): + if not zip_info.filename.lower().endswith(".pdf"): + continue + if "__MACOSX" in zip_info.filename or zip_info.filename.startswith("."): + continue + if "thumbs.db" in zip_info.filename.lower(): + continue + + total += 1 + try: + basename = Path(zip_info.filename).name + recognition = parse_nibis_filename(basename) + if not recognition.fach: + errors += 1 + logger.warning(f"Konnte Fach nicht erkennen: {basename}") + continue + + doc_id = str(uuid.uuid4()) + file_ext = Path(basename).suffix + safe_filename = f"{doc_id}{file_ext}" + file_path = DOCS_DIR / safe_filename + + with zip_ref.open(zip_info.filename) as source: + file_content = source.read() + with open(file_path, "wb") as target: + target.write(file_content) + + now = datetime.utcnow() + dokument = AbiturDokument( + id=doc_id, dateiname=safe_filename, original_dateiname=basename, + bundesland=bundesland, fach=recognition.fach, + jahr=recognition.jahr or datetime.now().year, + niveau=recognition.niveau or Niveau.EA, + typ=recognition.typ or DokumentTyp.AUFGABE, + aufgaben_nummer=recognition.aufgaben_nummer, + status=VerarbeitungsStatus.RECOGNIZED, confidence=recognition.confidence, + file_path=str(file_path), file_size=len(file_content), + indexed=False, vector_ids=[], created_at=now, updated_at=now + ) + _dokumente[doc_id] = dokument + documents.append(_to_dokument_response(dokument)) + recognized += 1 + except Exception as e: + errors += 1 + logger.error(f"Fehler bei {zip_info.filename}: {e}") + finally: + os.unlink(tmp_path) + + logger.info(f"ZIP-Import: {recognized}/{total} erkannt, {errors} Fehler") + return ImportResult(total_files=total, recognized=recognized, errors=errors, documents=documents) + + +@router.get("/", response_model=List[DokumentResponse]) +async def list_dokumente( + bundesland: Optional[Bundesland] = None, fach: Optional[Fach] = None, + jahr: Optional[int] = None, niveau: Optional[Niveau] = None, + typ: Optional[DokumentTyp] = None, status: Optional[VerarbeitungsStatus] = None, + indexed: Optional[bool] = None +): + """Listet Dokumente mit optionalen Filtern.""" + docs = list(_dokumente.values()) + if bundesland: + docs = [d for d in docs if d.bundesland == bundesland] + if fach: + docs = [d for d in docs if d.fach == fach] + if jahr: + docs = [d for d in docs if d.jahr == jahr] + if niveau: + docs = [d for d in docs if d.niveau == niveau] + if typ: + docs = [d for d in docs if d.typ == typ] + if status: + docs = [d for d in docs if d.status == status] + if indexed is not None: + docs = [d for d in docs if d.indexed == indexed] + docs.sort(key=lambda x: (x.jahr, x.fach.value, x.niveau.value), reverse=True) + return [_to_dokument_response(d) for d in docs] + + +@router.get("/{doc_id}", response_model=DokumentResponse) +async def get_dokument(doc_id: str): + """Ruft ein Dokument ab.""" + doc = _dokumente.get(doc_id) + if not doc: + raise HTTPException(status_code=404, detail="Dokument nicht gefunden") + return _to_dokument_response(doc) + + +@router.put("/{doc_id}", response_model=DokumentResponse) +async def update_dokument(doc_id: str, data: DokumentUpdate): + """Aktualisiert Dokument-Metadaten.""" + doc = _dokumente.get(doc_id) + if not doc: + raise HTTPException(status_code=404, detail="Dokument nicht gefunden") + if data.bundesland is not None: + doc.bundesland = data.bundesland + if data.fach is not None: + doc.fach = data.fach + if data.jahr is not None: + doc.jahr = data.jahr + if data.niveau is not None: + doc.niveau = data.niveau + if data.typ is not None: + doc.typ = data.typ + if data.aufgaben_nummer is not None: + doc.aufgaben_nummer = data.aufgaben_nummer + if data.status is not None: + doc.status = data.status + doc.updated_at = datetime.utcnow() + return _to_dokument_response(doc) + + +@router.post("/{doc_id}/confirm", response_model=DokumentResponse) +async def confirm_dokument(doc_id: str): + """Bestätigt erkannte Metadaten.""" + doc = _dokumente.get(doc_id) + if not doc: + raise HTTPException(status_code=404, detail="Dokument nicht gefunden") + doc.status = VerarbeitungsStatus.CONFIRMED + doc.updated_at = datetime.utcnow() + return _to_dokument_response(doc) + + +@router.post("/{doc_id}/index", response_model=DokumentResponse) +async def index_dokument(doc_id: str): + """Indiziert Dokument im Vector Store.""" + doc = _dokumente.get(doc_id) + if not doc: + raise HTTPException(status_code=404, detail="Dokument nicht gefunden") + if doc.status not in [VerarbeitungsStatus.CONFIRMED, VerarbeitungsStatus.RECOGNIZED]: + raise HTTPException(status_code=400, detail="Dokument muss erst bestätigt werden") + doc.indexed = True + doc.vector_ids = [f"vec_{doc_id}_{i}" for i in range(3)] + doc.status = VerarbeitungsStatus.INDEXED + doc.updated_at = datetime.utcnow() + logger.info(f"Document {doc_id} indexed (demo)") + return _to_dokument_response(doc) + + +@router.delete("/{doc_id}") +async def delete_dokument(doc_id: str): + """Löscht ein Dokument.""" + doc = _dokumente.get(doc_id) + if not doc: + raise HTTPException(status_code=404, detail="Dokument nicht gefunden") + if os.path.exists(doc.file_path): + os.remove(doc.file_path) + del _dokumente[doc_id] + return {"status": "deleted", "id": doc_id} + + +@router.get("/{doc_id}/download") +async def download_dokument(doc_id: str): + """Lädt Dokument herunter.""" + doc = _dokumente.get(doc_id) + if not doc: + raise HTTPException(status_code=404, detail="Dokument nicht gefunden") + if not os.path.exists(doc.file_path): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + return FileResponse(doc.file_path, filename=doc.original_dateiname, media_type="application/pdf") + + +@router.post("/recognize", response_model=RecognitionResult) +async def recognize_filename(filename: str): + """Erkennt Metadaten aus einem Dateinamen.""" + return parse_nibis_filename(filename) + + +@router.post("/bulk-confirm") +async def bulk_confirm(doc_ids: List[str]): + """Bestätigt mehrere Dokumente auf einmal.""" + confirmed = 0 + for doc_id in doc_ids: + doc = _dokumente.get(doc_id) + if doc and doc.status == VerarbeitungsStatus.RECOGNIZED: + doc.status = VerarbeitungsStatus.CONFIRMED + doc.updated_at = datetime.utcnow() + confirmed += 1 + return {"confirmed": confirmed, "total": len(doc_ids)} + + +@router.post("/bulk-index") +async def bulk_index(doc_ids: List[str]): + """Indiziert mehrere Dokumente auf einmal.""" + indexed = 0 + for doc_id in doc_ids: + doc = _dokumente.get(doc_id) + if doc and doc.status in [VerarbeitungsStatus.CONFIRMED, VerarbeitungsStatus.RECOGNIZED]: + doc.indexed = True + doc.vector_ids = [f"vec_{doc_id}_{i}" for i in range(3)] + doc.status = VerarbeitungsStatus.INDEXED + doc.updated_at = datetime.utcnow() + indexed += 1 + return {"indexed": indexed, "total": len(doc_ids)} + + +@router.get("/stats/overview") +async def get_stats_overview(): + """Gibt Übersicht über alle Dokumente.""" + docs = list(_dokumente.values()) + by_bundesland: Dict[str, int] = {} + by_fach: Dict[str, int] = {} + by_jahr: Dict[int, int] = {} + by_status: Dict[str, int] = {} + for doc in docs: + by_bundesland[doc.bundesland.value] = by_bundesland.get(doc.bundesland.value, 0) + 1 + by_fach[doc.fach.value] = by_fach.get(doc.fach.value, 0) + 1 + by_jahr[doc.jahr] = by_jahr.get(doc.jahr, 0) + 1 + by_status[doc.status.value] = by_status.get(doc.status.value, 0) + 1 + return { + "total": len(docs), "indexed": sum(1 for d in docs if d.indexed), + "pending": sum(1 for d in docs if d.status == VerarbeitungsStatus.PENDING), + "by_bundesland": by_bundesland, "by_fach": by_fach, "by_jahr": by_jahr, "by_status": by_status + } + + +@router.get("/search", response_model=List[DokumentResponse]) +async def search_dokumente( + bundesland: Bundesland, fach: Fach, jahr: Optional[int] = None, + niveau: Optional[Niveau] = None, nur_indexed: bool = True +): + """Sucht Dokumente für Klausur-Korrektur.""" + docs = [d for d in _dokumente.values() if d.bundesland == bundesland and d.fach == fach] + if jahr: + docs = [d for d in docs if d.jahr == jahr] + if niveau: + docs = [d for d in docs if d.niveau == niveau] + if nur_indexed: + docs = [d for d in docs if d.indexed] + + aufgaben = [d for d in docs if d.typ == DokumentTyp.AUFGABE] + ewh = [d for d in docs if d.typ == DokumentTyp.ERWARTUNGSHORIZONT] + andere = [d for d in docs if d.typ not in [DokumentTyp.AUFGABE, DokumentTyp.ERWARTUNGSHORIZONT]] + + result = [] + for aufgabe in aufgaben: + result.append(_to_dokument_response(aufgabe)) + matching_ewh = next( + (e for e in ewh if e.jahr == aufgabe.jahr and e.niveau == aufgabe.niveau + and e.aufgaben_nummer == aufgabe.aufgaben_nummer), None + ) + if matching_ewh: + result.append(_to_dokument_response(matching_ewh)) + for e in ewh: + if _to_dokument_response(e) not in result: + result.append(_to_dokument_response(e)) + for a in andere: + result.append(_to_dokument_response(a)) + return result + + +@router.get("/enums/bundeslaender") +async def get_bundeslaender(): + """Gibt alle Bundesländer zurück.""" + return [{"value": b.value, "label": b.value.replace("_", " ").title()} for b in Bundesland] + + +@router.get("/enums/faecher") +async def get_faecher(): + """Gibt alle Fächer zurück.""" + return [{"value": f.value, "label": FACH_LABELS.get(f, f.value)} for f in Fach] + + +@router.get("/enums/niveaus") +async def get_niveaus(): + """Gibt alle Niveaus zurück.""" + return [ + {"value": "eA", "label": "eA (erhöhtes Anforderungsniveau)"}, + {"value": "gA", "label": "gA (grundlegendes Anforderungsniveau)"} + ] + + +@router.get("/enums/typen") +async def get_typen(): + """Gibt alle Dokumenttypen zurück.""" + return [{"value": t.value, "label": DOKUMENT_TYP_LABELS.get(t, t.value)} for t in DokumentTyp] diff --git a/backend-lehrer/abitur/models.py b/backend-lehrer/abitur/models.py new file mode 100644 index 0000000..c49e6c1 --- /dev/null +++ b/backend-lehrer/abitur/models.py @@ -0,0 +1,327 @@ +""" +Abitur Document Store - Enums, Pydantic Models, Data Classes. + +Shared types for abitur_docs_api and abitur_docs_recognition. +""" + +from datetime import datetime +from typing import List, Dict, Any, Optional +from enum import Enum +from dataclasses import dataclass + +from pydantic import BaseModel, Field + + +# ============================================================================ +# Enums +# ============================================================================ + +class Bundesland(str, Enum): + """Bundesländer mit Zentralabitur.""" + NIEDERSACHSEN = "niedersachsen" + BAYERN = "bayern" + BADEN_WUERTTEMBERG = "baden_wuerttemberg" + NORDRHEIN_WESTFALEN = "nordrhein_westfalen" + HESSEN = "hessen" + SACHSEN = "sachsen" + THUERINGEN = "thueringen" + BERLIN = "berlin" + HAMBURG = "hamburg" + SCHLESWIG_HOLSTEIN = "schleswig_holstein" + BREMEN = "bremen" + BRANDENBURG = "brandenburg" + MECKLENBURG_VORPOMMERN = "mecklenburg_vorpommern" + SACHSEN_ANHALT = "sachsen_anhalt" + RHEINLAND_PFALZ = "rheinland_pfalz" + SAARLAND = "saarland" + + +class Fach(str, Enum): + """Abiturfächer.""" + DEUTSCH = "deutsch" + ENGLISCH = "englisch" + MATHEMATIK = "mathematik" + BIOLOGIE = "biologie" + CHEMIE = "chemie" + PHYSIK = "physik" + GESCHICHTE = "geschichte" + ERDKUNDE = "erdkunde" + POLITIK_WIRTSCHAFT = "politik_wirtschaft" + FRANZOESISCH = "franzoesisch" + SPANISCH = "spanisch" + LATEIN = "latein" + GRIECHISCH = "griechisch" + KUNST = "kunst" + MUSIK = "musik" + SPORT = "sport" + INFORMATIK = "informatik" + EV_RELIGION = "ev_religion" + KATH_RELIGION = "kath_religion" + WERTE_NORMEN = "werte_normen" + BRC = "brc" + BVW = "bvw" + ERNAEHRUNG = "ernaehrung" + MECHATRONIK = "mechatronik" + GESUNDHEIT_PFLEGE = "gesundheit_pflege" + PAEDAGOGIK_PSYCHOLOGIE = "paedagogik_psychologie" + + +class Niveau(str, Enum): + """Anforderungsniveau.""" + EA = "eA" + GA = "gA" + + +class DokumentTyp(str, Enum): + """Dokumenttyp.""" + AUFGABE = "aufgabe" + ERWARTUNGSHORIZONT = "erwartungshorizont" + DECKBLATT = "deckblatt" + MATERIAL = "material" + HOERVERSTEHEN = "hoerverstehen" + SPRACHMITTLUNG = "sprachmittlung" + BEWERTUNGSBOGEN = "bewertungsbogen" + + +class VerarbeitungsStatus(str, Enum): + """Status der Dokumentenverarbeitung.""" + PENDING = "pending" + PROCESSING = "processing" + RECOGNIZED = "recognized" + CONFIRMED = "confirmed" + INDEXED = "indexed" + ERROR = "error" + + +# ============================================================================ +# Fach-Mapping für Dateinamen +# ============================================================================ + +FACH_NAME_MAPPING = { + "deutsch": Fach.DEUTSCH, + "englisch": Fach.ENGLISCH, + "mathe": Fach.MATHEMATIK, + "mathematik": Fach.MATHEMATIK, + "biologie": Fach.BIOLOGIE, + "bio": Fach.BIOLOGIE, + "chemie": Fach.CHEMIE, + "physik": Fach.PHYSIK, + "geschichte": Fach.GESCHICHTE, + "erdkunde": Fach.ERDKUNDE, + "geographie": Fach.ERDKUNDE, + "politikwirtschaft": Fach.POLITIK_WIRTSCHAFT, + "politik": Fach.POLITIK_WIRTSCHAFT, + "franzoesisch": Fach.FRANZOESISCH, + "franz": Fach.FRANZOESISCH, + "spanisch": Fach.SPANISCH, + "latein": Fach.LATEIN, + "griechisch": Fach.GRIECHISCH, + "kunst": Fach.KUNST, + "musik": Fach.MUSIK, + "sport": Fach.SPORT, + "informatik": Fach.INFORMATIK, + "evreligion": Fach.EV_RELIGION, + "kathreligion": Fach.KATH_RELIGION, + "wertenormen": Fach.WERTE_NORMEN, + "brc": Fach.BRC, + "bvw": Fach.BVW, + "ernaehrung": Fach.ERNAEHRUNG, + "mecha": Fach.MECHATRONIK, + "mechatronik": Fach.MECHATRONIK, + "technikmecha": Fach.MECHATRONIK, + "gespfl": Fach.GESUNDHEIT_PFLEGE, + "paedpsych": Fach.PAEDAGOGIK_PSYCHOLOGIE, +} + + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class DokumentCreate(BaseModel): + """Manuelles Erstellen eines Dokuments.""" + bundesland: Bundesland + fach: Fach + jahr: int = Field(ge=2000, le=2100) + niveau: Niveau + typ: DokumentTyp + aufgaben_nummer: Optional[str] = None + + +class DokumentUpdate(BaseModel): + """Update für erkannte Metadaten.""" + bundesland: Optional[Bundesland] = None + fach: Optional[Fach] = None + jahr: Optional[int] = None + niveau: Optional[Niveau] = None + typ: Optional[DokumentTyp] = None + aufgaben_nummer: Optional[str] = None + status: Optional[VerarbeitungsStatus] = None + + +class DokumentResponse(BaseModel): + """Response für ein Dokument.""" + id: str + dateiname: str + original_dateiname: str + bundesland: Bundesland + fach: Fach + jahr: int + niveau: Niveau + typ: DokumentTyp + aufgaben_nummer: Optional[str] + status: VerarbeitungsStatus + confidence: float + file_path: str + file_size: int + indexed: bool + vector_ids: List[str] + created_at: datetime + updated_at: datetime + + +class ImportResult(BaseModel): + """Ergebnis eines ZIP-Imports.""" + total_files: int + recognized: int + errors: int + documents: List[DokumentResponse] + + +class RecognitionResult(BaseModel): + """Ergebnis der Dokumentenerkennung.""" + success: bool + bundesland: Optional[Bundesland] + fach: Optional[Fach] + jahr: Optional[int] + niveau: Optional[Niveau] + typ: Optional[DokumentTyp] + aufgaben_nummer: Optional[str] + confidence: float + raw_filename: str + suggestions: List[Dict[str, Any]] + + @property + def extracted(self) -> Dict[str, Any]: + """Backwards-compatible property returning extracted values as dict.""" + result = {} + if self.bundesland: + result["bundesland"] = self.bundesland.value + if self.fach: + result["fach"] = self.fach.value + if self.jahr: + result["jahr"] = self.jahr + if self.niveau: + result["niveau"] = self.niveau.value + if self.typ: + result["typ"] = self.typ.value + if self.aufgaben_nummer: + result["aufgaben_nummer"] = self.aufgaben_nummer + return result + + @property + def method(self) -> str: + """Backwards-compatible property for recognition method.""" + return "filename_pattern" + + +# ============================================================================ +# Internal Data Classes +# ============================================================================ + +@dataclass +class AbiturDokument: + """Internes Dokument.""" + id: str + dateiname: str + original_dateiname: str + bundesland: Bundesland + fach: Fach + jahr: int + niveau: Niveau + typ: DokumentTyp + aufgaben_nummer: Optional[str] + status: VerarbeitungsStatus + confidence: float + file_path: str + file_size: int + indexed: bool + vector_ids: List[str] + created_at: datetime + updated_at: datetime + + +# ============================================================================ +# Backwards-compatibility aliases (used by tests) +# ============================================================================ +AbiturFach = Fach +Anforderungsniveau = Niveau + + +class DocumentMetadata(BaseModel): + """Backwards-compatible metadata model for tests.""" + jahr: Optional[int] = None + bundesland: Optional[str] = None + fach: Optional[str] = None + niveau: Optional[str] = None + dokument_typ: Optional[str] = None + aufgaben_nummer: Optional[str] = None + + +class AbiturDokumentCompat(BaseModel): + """Backwards-compatible AbiturDokument model for tests.""" + id: str + filename: str + file_path: str + metadata: DocumentMetadata + status: VerarbeitungsStatus + recognition_result: Optional[RecognitionResult] = None + created_at: datetime + updated_at: datetime + + class Config: + arbitrary_types_allowed = True + + +# ============================================================================ +# Fach Labels (für Frontend Enum-Endpoint) +# ============================================================================ + +FACH_LABELS = { + Fach.DEUTSCH: "Deutsch", + Fach.ENGLISCH: "Englisch", + Fach.MATHEMATIK: "Mathematik", + Fach.BIOLOGIE: "Biologie", + Fach.CHEMIE: "Chemie", + Fach.PHYSIK: "Physik", + Fach.GESCHICHTE: "Geschichte", + Fach.ERDKUNDE: "Erdkunde", + Fach.POLITIK_WIRTSCHAFT: "Politik-Wirtschaft", + Fach.FRANZOESISCH: "Französisch", + Fach.SPANISCH: "Spanisch", + Fach.LATEIN: "Latein", + Fach.GRIECHISCH: "Griechisch", + Fach.KUNST: "Kunst", + Fach.MUSIK: "Musik", + Fach.SPORT: "Sport", + Fach.INFORMATIK: "Informatik", + Fach.EV_RELIGION: "Ev. Religion", + Fach.KATH_RELIGION: "Kath. Religion", + Fach.WERTE_NORMEN: "Werte und Normen", + Fach.BRC: "BRC (Betriebswirtschaft)", + Fach.BVW: "BVW (Volkswirtschaft)", + Fach.ERNAEHRUNG: "Ernährung", + Fach.MECHATRONIK: "Mechatronik", + Fach.GESUNDHEIT_PFLEGE: "Gesundheit-Pflege", + Fach.PAEDAGOGIK_PSYCHOLOGIE: "Pädagogik-Psychologie", +} + +DOKUMENT_TYP_LABELS = { + DokumentTyp.AUFGABE: "Aufgabe", + DokumentTyp.ERWARTUNGSHORIZONT: "Erwartungshorizont", + DokumentTyp.DECKBLATT: "Deckblatt", + DokumentTyp.MATERIAL: "Material", + DokumentTyp.HOERVERSTEHEN: "Hörverstehen", + DokumentTyp.SPRACHMITTLUNG: "Sprachmittlung", + DokumentTyp.BEWERTUNGSBOGEN: "Bewertungsbogen", +} diff --git a/backend-lehrer/abitur/recognition.py b/backend-lehrer/abitur/recognition.py new file mode 100644 index 0000000..8dea4b0 --- /dev/null +++ b/backend-lehrer/abitur/recognition.py @@ -0,0 +1,124 @@ +""" +Abitur Document Store - Dateinamen-Erkennung und Helfer. + +Erkennt Metadaten aus NiBiS-Dateinamen (Niedersachsen). +""" + +import re +from typing import Dict, Any +from pathlib import Path + +from .models import ( + Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus, + RecognitionResult, AbiturDokument, DokumentResponse, + FACH_NAME_MAPPING, +) + + +def parse_nibis_filename(filename: str) -> RecognitionResult: + """ + Erkennt Metadaten aus NiBiS-Dateinamen. + + Beispiele: + - 2025_Deutsch_eA_I.pdf + - 2025_Deutsch_eA_I_EWH.pdf + - 2025_Biologie_gA_1.pdf + - 2025_Englisch_eA_HV.pdf (Hörverstehen) + """ + result = RecognitionResult( + success=False, + bundesland=Bundesland.NIEDERSACHSEN, + fach=None, + jahr=None, + niveau=None, + typ=None, + aufgaben_nummer=None, + confidence=0.0, + raw_filename=filename, + suggestions=[] + ) + + # Bereinige Dateiname + name = Path(filename).stem.lower() + + # Extrahiere Jahr (4 Ziffern am Anfang) + jahr_match = re.match(r'^(\d{4})', name) + if jahr_match: + result.jahr = int(jahr_match.group(1)) + result.confidence += 0.2 + + # Extrahiere Fach + for fach_key, fach_enum in FACH_NAME_MAPPING.items(): + if fach_key in name.replace("_", "").replace("-", ""): + result.fach = fach_enum + result.confidence += 0.3 + break + + # Extrahiere Niveau (eA/gA) + if "_ea" in name or "_ea_" in name or "ea_" in name: + result.niveau = Niveau.EA + result.confidence += 0.2 + elif "_ga" in name or "_ga_" in name or "ga_" in name: + result.niveau = Niveau.GA + result.confidence += 0.2 + + # Extrahiere Typ + if "_ewh" in name: + result.typ = DokumentTyp.ERWARTUNGSHORIZONT + result.confidence += 0.2 + elif "_hv" in name or "hoerverstehen" in name: + result.typ = DokumentTyp.HOERVERSTEHEN + result.confidence += 0.15 + elif "_sm" in name or "_me" in name or "sprachmittlung" in name: + result.typ = DokumentTyp.SPRACHMITTLUNG + result.confidence += 0.15 + elif "deckblatt" in name: + result.typ = DokumentTyp.DECKBLATT + result.confidence += 0.15 + elif "material" in name: + result.typ = DokumentTyp.MATERIAL + result.confidence += 0.15 + elif "bewertung" in name: + result.typ = DokumentTyp.BEWERTUNGSBOGEN + result.confidence += 0.15 + else: + result.typ = DokumentTyp.AUFGABE + result.confidence += 0.1 + + # Extrahiere Aufgabennummer (römisch oder arabisch) + aufgabe_match = re.search(r'_([ivx]+|[1-4][abc]?)(?:_|\.pdf|$)', name, re.IGNORECASE) + if aufgabe_match: + result.aufgaben_nummer = aufgabe_match.group(1).upper() + result.confidence += 0.1 + + # Erfolg wenn mindestens Fach und Jahr erkannt + if result.fach and result.jahr: + result.success = True + + # Normalisiere Confidence auf max 1.0 + result.confidence = min(result.confidence, 1.0) + + return result + + +def to_dokument_response(doc: AbiturDokument) -> DokumentResponse: + """Konvertiert internes Dokument zu Response.""" + return DokumentResponse( + id=doc.id, + dateiname=doc.dateiname, + original_dateiname=doc.original_dateiname, + bundesland=doc.bundesland, + fach=doc.fach, + jahr=doc.jahr, + niveau=doc.niveau, + typ=doc.typ, + aufgaben_nummer=doc.aufgaben_nummer, + status=doc.status, + confidence=doc.confidence, + file_path=doc.file_path, + file_size=doc.file_size, + indexed=doc.indexed, + vector_ids=doc.vector_ids, + created_at=doc.created_at, + updated_at=doc.updated_at + ) diff --git a/backend-lehrer/abitur_docs_api.py b/backend-lehrer/abitur_docs_api.py index bcf0190..4189b81 100644 --- a/backend-lehrer/abitur_docs_api.py +++ b/backend-lehrer/abitur_docs_api.py @@ -1,413 +1,4 @@ -""" -Abitur Document Store API - Verwaltung von Abitur-Aufgaben und Erwartungshorizonten. - -Unterstützt: -- Bundesland-spezifische Dokumente -- Fach, Jahr, Niveau (eA/gA), Aufgabennummer -- KI-basierte Dokumentenerkennung -- RAG-Integration mit Vector Store - -Dateinamen-Schema (NiBiS Niedersachsen): -- 2025_Deutsch_eA_I.pdf - Aufgabe -- 2025_Deutsch_eA_I_EWH.pdf - Erwartungshorizont -""" - -import logging -import uuid -import os -import zipfile -import tempfile -from datetime import datetime -from typing import List, Optional, Dict, Any -from pathlib import Path - -from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks -from fastapi.responses import FileResponse - -from abitur_docs_models import ( - Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus, - DokumentCreate, DokumentUpdate, DokumentResponse, ImportResult, - RecognitionResult, AbiturDokument, - FACH_LABELS, DOKUMENT_TYP_LABELS, - # Backwards-compatibility re-exports - AbiturFach, Anforderungsniveau, DocumentMetadata, AbiturDokumentCompat, -) -from abitur_docs_recognition import parse_nibis_filename, to_dokument_response - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/abitur-docs", - tags=["abitur-docs"], -) - -# Storage directory -DOCS_DIR = Path("/tmp/abitur-docs") -DOCS_DIR.mkdir(parents=True, exist_ok=True) - -# In-Memory Storage -_dokumente: Dict[str, AbiturDokument] = {} - -# Backwards-compatibility alias -documents_db = _dokumente - - -# ============================================================================ -# Private helper (kept local since it references module-level _dokumente) -# ============================================================================ - -def _to_dokument_response(doc: AbiturDokument) -> DokumentResponse: - return to_dokument_response(doc) - - -# ============================================================================ -# API Endpoints - Dokumente -# ============================================================================ - -@router.post("/upload", response_model=DokumentResponse) -async def upload_dokument( - file: UploadFile = File(...), - bundesland: Optional[Bundesland] = Form(None), - fach: Optional[Fach] = Form(None), - jahr: Optional[int] = Form(None), - niveau: Optional[Niveau] = Form(None), - typ: Optional[DokumentTyp] = Form(None), - aufgaben_nummer: Optional[str] = Form(None) -): - """Lädt ein einzelnes Dokument hoch.""" - if not file.filename: - raise HTTPException(status_code=400, detail="Kein Dateiname") - - recognition = parse_nibis_filename(file.filename) - - final_bundesland = bundesland or recognition.bundesland or Bundesland.NIEDERSACHSEN - final_fach = fach or recognition.fach - final_jahr = jahr or recognition.jahr or datetime.now().year - final_niveau = niveau or recognition.niveau or Niveau.EA - final_typ = typ or recognition.typ or DokumentTyp.AUFGABE - final_aufgabe = aufgaben_nummer or recognition.aufgaben_nummer - - if not final_fach: - raise HTTPException(status_code=400, detail="Fach konnte nicht erkannt werden") - - doc_id = str(uuid.uuid4()) - file_ext = Path(file.filename).suffix - safe_filename = f"{doc_id}{file_ext}" - file_path = DOCS_DIR / safe_filename - - content = await file.read() - with open(file_path, "wb") as f: - f.write(content) - - now = datetime.utcnow() - dokument = AbiturDokument( - id=doc_id, dateiname=safe_filename, original_dateiname=file.filename, - bundesland=final_bundesland, fach=final_fach, jahr=final_jahr, - niveau=final_niveau, typ=final_typ, aufgaben_nummer=final_aufgabe, - status=VerarbeitungsStatus.RECOGNIZED if recognition.success else VerarbeitungsStatus.PENDING, - confidence=recognition.confidence, file_path=str(file_path), file_size=len(content), - indexed=False, vector_ids=[], created_at=now, updated_at=now - ) - _dokumente[doc_id] = dokument - logger.info(f"Uploaded document {doc_id}: {file.filename}") - return _to_dokument_response(dokument) - - -@router.post("/import-zip", response_model=ImportResult) -async def import_zip( - file: UploadFile = File(...), - bundesland: Bundesland = Form(Bundesland.NIEDERSACHSEN), - background_tasks: BackgroundTasks = None -): - """Importiert alle PDFs aus einer ZIP-Datei.""" - if not file.filename or not file.filename.endswith(".zip"): - raise HTTPException(status_code=400, detail="ZIP-Datei erforderlich") - - with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: - content = await file.read() - tmp.write(content) - tmp_path = tmp.name - - documents = [] - total = 0 - recognized = 0 - errors = 0 - - try: - with zipfile.ZipFile(tmp_path, 'r') as zip_ref: - for zip_info in zip_ref.infolist(): - if not zip_info.filename.lower().endswith(".pdf"): - continue - if "__MACOSX" in zip_info.filename or zip_info.filename.startswith("."): - continue - if "thumbs.db" in zip_info.filename.lower(): - continue - - total += 1 - try: - basename = Path(zip_info.filename).name - recognition = parse_nibis_filename(basename) - if not recognition.fach: - errors += 1 - logger.warning(f"Konnte Fach nicht erkennen: {basename}") - continue - - doc_id = str(uuid.uuid4()) - file_ext = Path(basename).suffix - safe_filename = f"{doc_id}{file_ext}" - file_path = DOCS_DIR / safe_filename - - with zip_ref.open(zip_info.filename) as source: - file_content = source.read() - with open(file_path, "wb") as target: - target.write(file_content) - - now = datetime.utcnow() - dokument = AbiturDokument( - id=doc_id, dateiname=safe_filename, original_dateiname=basename, - bundesland=bundesland, fach=recognition.fach, - jahr=recognition.jahr or datetime.now().year, - niveau=recognition.niveau or Niveau.EA, - typ=recognition.typ or DokumentTyp.AUFGABE, - aufgaben_nummer=recognition.aufgaben_nummer, - status=VerarbeitungsStatus.RECOGNIZED, confidence=recognition.confidence, - file_path=str(file_path), file_size=len(file_content), - indexed=False, vector_ids=[], created_at=now, updated_at=now - ) - _dokumente[doc_id] = dokument - documents.append(_to_dokument_response(dokument)) - recognized += 1 - except Exception as e: - errors += 1 - logger.error(f"Fehler bei {zip_info.filename}: {e}") - finally: - os.unlink(tmp_path) - - logger.info(f"ZIP-Import: {recognized}/{total} erkannt, {errors} Fehler") - return ImportResult(total_files=total, recognized=recognized, errors=errors, documents=documents) - - -@router.get("/", response_model=List[DokumentResponse]) -async def list_dokumente( - bundesland: Optional[Bundesland] = None, fach: Optional[Fach] = None, - jahr: Optional[int] = None, niveau: Optional[Niveau] = None, - typ: Optional[DokumentTyp] = None, status: Optional[VerarbeitungsStatus] = None, - indexed: Optional[bool] = None -): - """Listet Dokumente mit optionalen Filtern.""" - docs = list(_dokumente.values()) - if bundesland: - docs = [d for d in docs if d.bundesland == bundesland] - if fach: - docs = [d for d in docs if d.fach == fach] - if jahr: - docs = [d for d in docs if d.jahr == jahr] - if niveau: - docs = [d for d in docs if d.niveau == niveau] - if typ: - docs = [d for d in docs if d.typ == typ] - if status: - docs = [d for d in docs if d.status == status] - if indexed is not None: - docs = [d for d in docs if d.indexed == indexed] - docs.sort(key=lambda x: (x.jahr, x.fach.value, x.niveau.value), reverse=True) - return [_to_dokument_response(d) for d in docs] - - -@router.get("/{doc_id}", response_model=DokumentResponse) -async def get_dokument(doc_id: str): - """Ruft ein Dokument ab.""" - doc = _dokumente.get(doc_id) - if not doc: - raise HTTPException(status_code=404, detail="Dokument nicht gefunden") - return _to_dokument_response(doc) - - -@router.put("/{doc_id}", response_model=DokumentResponse) -async def update_dokument(doc_id: str, data: DokumentUpdate): - """Aktualisiert Dokument-Metadaten.""" - doc = _dokumente.get(doc_id) - if not doc: - raise HTTPException(status_code=404, detail="Dokument nicht gefunden") - if data.bundesland is not None: - doc.bundesland = data.bundesland - if data.fach is not None: - doc.fach = data.fach - if data.jahr is not None: - doc.jahr = data.jahr - if data.niveau is not None: - doc.niveau = data.niveau - if data.typ is not None: - doc.typ = data.typ - if data.aufgaben_nummer is not None: - doc.aufgaben_nummer = data.aufgaben_nummer - if data.status is not None: - doc.status = data.status - doc.updated_at = datetime.utcnow() - return _to_dokument_response(doc) - - -@router.post("/{doc_id}/confirm", response_model=DokumentResponse) -async def confirm_dokument(doc_id: str): - """Bestätigt erkannte Metadaten.""" - doc = _dokumente.get(doc_id) - if not doc: - raise HTTPException(status_code=404, detail="Dokument nicht gefunden") - doc.status = VerarbeitungsStatus.CONFIRMED - doc.updated_at = datetime.utcnow() - return _to_dokument_response(doc) - - -@router.post("/{doc_id}/index", response_model=DokumentResponse) -async def index_dokument(doc_id: str): - """Indiziert Dokument im Vector Store.""" - doc = _dokumente.get(doc_id) - if not doc: - raise HTTPException(status_code=404, detail="Dokument nicht gefunden") - if doc.status not in [VerarbeitungsStatus.CONFIRMED, VerarbeitungsStatus.RECOGNIZED]: - raise HTTPException(status_code=400, detail="Dokument muss erst bestätigt werden") - doc.indexed = True - doc.vector_ids = [f"vec_{doc_id}_{i}" for i in range(3)] - doc.status = VerarbeitungsStatus.INDEXED - doc.updated_at = datetime.utcnow() - logger.info(f"Document {doc_id} indexed (demo)") - return _to_dokument_response(doc) - - -@router.delete("/{doc_id}") -async def delete_dokument(doc_id: str): - """Löscht ein Dokument.""" - doc = _dokumente.get(doc_id) - if not doc: - raise HTTPException(status_code=404, detail="Dokument nicht gefunden") - if os.path.exists(doc.file_path): - os.remove(doc.file_path) - del _dokumente[doc_id] - return {"status": "deleted", "id": doc_id} - - -@router.get("/{doc_id}/download") -async def download_dokument(doc_id: str): - """Lädt Dokument herunter.""" - doc = _dokumente.get(doc_id) - if not doc: - raise HTTPException(status_code=404, detail="Dokument nicht gefunden") - if not os.path.exists(doc.file_path): - raise HTTPException(status_code=404, detail="Datei nicht gefunden") - return FileResponse(doc.file_path, filename=doc.original_dateiname, media_type="application/pdf") - - -@router.post("/recognize", response_model=RecognitionResult) -async def recognize_filename(filename: str): - """Erkennt Metadaten aus einem Dateinamen.""" - return parse_nibis_filename(filename) - - -@router.post("/bulk-confirm") -async def bulk_confirm(doc_ids: List[str]): - """Bestätigt mehrere Dokumente auf einmal.""" - confirmed = 0 - for doc_id in doc_ids: - doc = _dokumente.get(doc_id) - if doc and doc.status == VerarbeitungsStatus.RECOGNIZED: - doc.status = VerarbeitungsStatus.CONFIRMED - doc.updated_at = datetime.utcnow() - confirmed += 1 - return {"confirmed": confirmed, "total": len(doc_ids)} - - -@router.post("/bulk-index") -async def bulk_index(doc_ids: List[str]): - """Indiziert mehrere Dokumente auf einmal.""" - indexed = 0 - for doc_id in doc_ids: - doc = _dokumente.get(doc_id) - if doc and doc.status in [VerarbeitungsStatus.CONFIRMED, VerarbeitungsStatus.RECOGNIZED]: - doc.indexed = True - doc.vector_ids = [f"vec_{doc_id}_{i}" for i in range(3)] - doc.status = VerarbeitungsStatus.INDEXED - doc.updated_at = datetime.utcnow() - indexed += 1 - return {"indexed": indexed, "total": len(doc_ids)} - - -@router.get("/stats/overview") -async def get_stats_overview(): - """Gibt Übersicht über alle Dokumente.""" - docs = list(_dokumente.values()) - by_bundesland: Dict[str, int] = {} - by_fach: Dict[str, int] = {} - by_jahr: Dict[int, int] = {} - by_status: Dict[str, int] = {} - for doc in docs: - by_bundesland[doc.bundesland.value] = by_bundesland.get(doc.bundesland.value, 0) + 1 - by_fach[doc.fach.value] = by_fach.get(doc.fach.value, 0) + 1 - by_jahr[doc.jahr] = by_jahr.get(doc.jahr, 0) + 1 - by_status[doc.status.value] = by_status.get(doc.status.value, 0) + 1 - return { - "total": len(docs), "indexed": sum(1 for d in docs if d.indexed), - "pending": sum(1 for d in docs if d.status == VerarbeitungsStatus.PENDING), - "by_bundesland": by_bundesland, "by_fach": by_fach, "by_jahr": by_jahr, "by_status": by_status - } - - -@router.get("/search", response_model=List[DokumentResponse]) -async def search_dokumente( - bundesland: Bundesland, fach: Fach, jahr: Optional[int] = None, - niveau: Optional[Niveau] = None, nur_indexed: bool = True -): - """Sucht Dokumente für Klausur-Korrektur.""" - docs = [d for d in _dokumente.values() if d.bundesland == bundesland and d.fach == fach] - if jahr: - docs = [d for d in docs if d.jahr == jahr] - if niveau: - docs = [d for d in docs if d.niveau == niveau] - if nur_indexed: - docs = [d for d in docs if d.indexed] - - aufgaben = [d for d in docs if d.typ == DokumentTyp.AUFGABE] - ewh = [d for d in docs if d.typ == DokumentTyp.ERWARTUNGSHORIZONT] - andere = [d for d in docs if d.typ not in [DokumentTyp.AUFGABE, DokumentTyp.ERWARTUNGSHORIZONT]] - - result = [] - for aufgabe in aufgaben: - result.append(_to_dokument_response(aufgabe)) - matching_ewh = next( - (e for e in ewh if e.jahr == aufgabe.jahr and e.niveau == aufgabe.niveau - and e.aufgaben_nummer == aufgabe.aufgaben_nummer), None - ) - if matching_ewh: - result.append(_to_dokument_response(matching_ewh)) - for e in ewh: - if _to_dokument_response(e) not in result: - result.append(_to_dokument_response(e)) - for a in andere: - result.append(_to_dokument_response(a)) - return result - - -@router.get("/enums/bundeslaender") -async def get_bundeslaender(): - """Gibt alle Bundesländer zurück.""" - return [{"value": b.value, "label": b.value.replace("_", " ").title()} for b in Bundesland] - - -@router.get("/enums/faecher") -async def get_faecher(): - """Gibt alle Fächer zurück.""" - return [{"value": f.value, "label": FACH_LABELS.get(f, f.value)} for f in Fach] - - -@router.get("/enums/niveaus") -async def get_niveaus(): - """Gibt alle Niveaus zurück.""" - return [ - {"value": "eA", "label": "eA (erhöhtes Anforderungsniveau)"}, - {"value": "gA", "label": "gA (grundlegendes Anforderungsniveau)"} - ] - - -@router.get("/enums/typen") -async def get_typen(): - """Gibt alle Dokumenttypen zurück.""" - return [{"value": t.value, "label": DOKUMENT_TYP_LABELS.get(t, t.value)} for t in DokumentTyp] +# Backward-compat shim -- module moved to abitur/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("abitur.api") diff --git a/backend-lehrer/abitur_docs_models.py b/backend-lehrer/abitur_docs_models.py index c49e6c1..798f629 100644 --- a/backend-lehrer/abitur_docs_models.py +++ b/backend-lehrer/abitur_docs_models.py @@ -1,327 +1,4 @@ -""" -Abitur Document Store - Enums, Pydantic Models, Data Classes. - -Shared types for abitur_docs_api and abitur_docs_recognition. -""" - -from datetime import datetime -from typing import List, Dict, Any, Optional -from enum import Enum -from dataclasses import dataclass - -from pydantic import BaseModel, Field - - -# ============================================================================ -# Enums -# ============================================================================ - -class Bundesland(str, Enum): - """Bundesländer mit Zentralabitur.""" - NIEDERSACHSEN = "niedersachsen" - BAYERN = "bayern" - BADEN_WUERTTEMBERG = "baden_wuerttemberg" - NORDRHEIN_WESTFALEN = "nordrhein_westfalen" - HESSEN = "hessen" - SACHSEN = "sachsen" - THUERINGEN = "thueringen" - BERLIN = "berlin" - HAMBURG = "hamburg" - SCHLESWIG_HOLSTEIN = "schleswig_holstein" - BREMEN = "bremen" - BRANDENBURG = "brandenburg" - MECKLENBURG_VORPOMMERN = "mecklenburg_vorpommern" - SACHSEN_ANHALT = "sachsen_anhalt" - RHEINLAND_PFALZ = "rheinland_pfalz" - SAARLAND = "saarland" - - -class Fach(str, Enum): - """Abiturfächer.""" - DEUTSCH = "deutsch" - ENGLISCH = "englisch" - MATHEMATIK = "mathematik" - BIOLOGIE = "biologie" - CHEMIE = "chemie" - PHYSIK = "physik" - GESCHICHTE = "geschichte" - ERDKUNDE = "erdkunde" - POLITIK_WIRTSCHAFT = "politik_wirtschaft" - FRANZOESISCH = "franzoesisch" - SPANISCH = "spanisch" - LATEIN = "latein" - GRIECHISCH = "griechisch" - KUNST = "kunst" - MUSIK = "musik" - SPORT = "sport" - INFORMATIK = "informatik" - EV_RELIGION = "ev_religion" - KATH_RELIGION = "kath_religion" - WERTE_NORMEN = "werte_normen" - BRC = "brc" - BVW = "bvw" - ERNAEHRUNG = "ernaehrung" - MECHATRONIK = "mechatronik" - GESUNDHEIT_PFLEGE = "gesundheit_pflege" - PAEDAGOGIK_PSYCHOLOGIE = "paedagogik_psychologie" - - -class Niveau(str, Enum): - """Anforderungsniveau.""" - EA = "eA" - GA = "gA" - - -class DokumentTyp(str, Enum): - """Dokumenttyp.""" - AUFGABE = "aufgabe" - ERWARTUNGSHORIZONT = "erwartungshorizont" - DECKBLATT = "deckblatt" - MATERIAL = "material" - HOERVERSTEHEN = "hoerverstehen" - SPRACHMITTLUNG = "sprachmittlung" - BEWERTUNGSBOGEN = "bewertungsbogen" - - -class VerarbeitungsStatus(str, Enum): - """Status der Dokumentenverarbeitung.""" - PENDING = "pending" - PROCESSING = "processing" - RECOGNIZED = "recognized" - CONFIRMED = "confirmed" - INDEXED = "indexed" - ERROR = "error" - - -# ============================================================================ -# Fach-Mapping für Dateinamen -# ============================================================================ - -FACH_NAME_MAPPING = { - "deutsch": Fach.DEUTSCH, - "englisch": Fach.ENGLISCH, - "mathe": Fach.MATHEMATIK, - "mathematik": Fach.MATHEMATIK, - "biologie": Fach.BIOLOGIE, - "bio": Fach.BIOLOGIE, - "chemie": Fach.CHEMIE, - "physik": Fach.PHYSIK, - "geschichte": Fach.GESCHICHTE, - "erdkunde": Fach.ERDKUNDE, - "geographie": Fach.ERDKUNDE, - "politikwirtschaft": Fach.POLITIK_WIRTSCHAFT, - "politik": Fach.POLITIK_WIRTSCHAFT, - "franzoesisch": Fach.FRANZOESISCH, - "franz": Fach.FRANZOESISCH, - "spanisch": Fach.SPANISCH, - "latein": Fach.LATEIN, - "griechisch": Fach.GRIECHISCH, - "kunst": Fach.KUNST, - "musik": Fach.MUSIK, - "sport": Fach.SPORT, - "informatik": Fach.INFORMATIK, - "evreligion": Fach.EV_RELIGION, - "kathreligion": Fach.KATH_RELIGION, - "wertenormen": Fach.WERTE_NORMEN, - "brc": Fach.BRC, - "bvw": Fach.BVW, - "ernaehrung": Fach.ERNAEHRUNG, - "mecha": Fach.MECHATRONIK, - "mechatronik": Fach.MECHATRONIK, - "technikmecha": Fach.MECHATRONIK, - "gespfl": Fach.GESUNDHEIT_PFLEGE, - "paedpsych": Fach.PAEDAGOGIK_PSYCHOLOGIE, -} - - -# ============================================================================ -# Pydantic Models -# ============================================================================ - -class DokumentCreate(BaseModel): - """Manuelles Erstellen eines Dokuments.""" - bundesland: Bundesland - fach: Fach - jahr: int = Field(ge=2000, le=2100) - niveau: Niveau - typ: DokumentTyp - aufgaben_nummer: Optional[str] = None - - -class DokumentUpdate(BaseModel): - """Update für erkannte Metadaten.""" - bundesland: Optional[Bundesland] = None - fach: Optional[Fach] = None - jahr: Optional[int] = None - niveau: Optional[Niveau] = None - typ: Optional[DokumentTyp] = None - aufgaben_nummer: Optional[str] = None - status: Optional[VerarbeitungsStatus] = None - - -class DokumentResponse(BaseModel): - """Response für ein Dokument.""" - id: str - dateiname: str - original_dateiname: str - bundesland: Bundesland - fach: Fach - jahr: int - niveau: Niveau - typ: DokumentTyp - aufgaben_nummer: Optional[str] - status: VerarbeitungsStatus - confidence: float - file_path: str - file_size: int - indexed: bool - vector_ids: List[str] - created_at: datetime - updated_at: datetime - - -class ImportResult(BaseModel): - """Ergebnis eines ZIP-Imports.""" - total_files: int - recognized: int - errors: int - documents: List[DokumentResponse] - - -class RecognitionResult(BaseModel): - """Ergebnis der Dokumentenerkennung.""" - success: bool - bundesland: Optional[Bundesland] - fach: Optional[Fach] - jahr: Optional[int] - niveau: Optional[Niveau] - typ: Optional[DokumentTyp] - aufgaben_nummer: Optional[str] - confidence: float - raw_filename: str - suggestions: List[Dict[str, Any]] - - @property - def extracted(self) -> Dict[str, Any]: - """Backwards-compatible property returning extracted values as dict.""" - result = {} - if self.bundesland: - result["bundesland"] = self.bundesland.value - if self.fach: - result["fach"] = self.fach.value - if self.jahr: - result["jahr"] = self.jahr - if self.niveau: - result["niveau"] = self.niveau.value - if self.typ: - result["typ"] = self.typ.value - if self.aufgaben_nummer: - result["aufgaben_nummer"] = self.aufgaben_nummer - return result - - @property - def method(self) -> str: - """Backwards-compatible property for recognition method.""" - return "filename_pattern" - - -# ============================================================================ -# Internal Data Classes -# ============================================================================ - -@dataclass -class AbiturDokument: - """Internes Dokument.""" - id: str - dateiname: str - original_dateiname: str - bundesland: Bundesland - fach: Fach - jahr: int - niveau: Niveau - typ: DokumentTyp - aufgaben_nummer: Optional[str] - status: VerarbeitungsStatus - confidence: float - file_path: str - file_size: int - indexed: bool - vector_ids: List[str] - created_at: datetime - updated_at: datetime - - -# ============================================================================ -# Backwards-compatibility aliases (used by tests) -# ============================================================================ -AbiturFach = Fach -Anforderungsniveau = Niveau - - -class DocumentMetadata(BaseModel): - """Backwards-compatible metadata model for tests.""" - jahr: Optional[int] = None - bundesland: Optional[str] = None - fach: Optional[str] = None - niveau: Optional[str] = None - dokument_typ: Optional[str] = None - aufgaben_nummer: Optional[str] = None - - -class AbiturDokumentCompat(BaseModel): - """Backwards-compatible AbiturDokument model for tests.""" - id: str - filename: str - file_path: str - metadata: DocumentMetadata - status: VerarbeitungsStatus - recognition_result: Optional[RecognitionResult] = None - created_at: datetime - updated_at: datetime - - class Config: - arbitrary_types_allowed = True - - -# ============================================================================ -# Fach Labels (für Frontend Enum-Endpoint) -# ============================================================================ - -FACH_LABELS = { - Fach.DEUTSCH: "Deutsch", - Fach.ENGLISCH: "Englisch", - Fach.MATHEMATIK: "Mathematik", - Fach.BIOLOGIE: "Biologie", - Fach.CHEMIE: "Chemie", - Fach.PHYSIK: "Physik", - Fach.GESCHICHTE: "Geschichte", - Fach.ERDKUNDE: "Erdkunde", - Fach.POLITIK_WIRTSCHAFT: "Politik-Wirtschaft", - Fach.FRANZOESISCH: "Französisch", - Fach.SPANISCH: "Spanisch", - Fach.LATEIN: "Latein", - Fach.GRIECHISCH: "Griechisch", - Fach.KUNST: "Kunst", - Fach.MUSIK: "Musik", - Fach.SPORT: "Sport", - Fach.INFORMATIK: "Informatik", - Fach.EV_RELIGION: "Ev. Religion", - Fach.KATH_RELIGION: "Kath. Religion", - Fach.WERTE_NORMEN: "Werte und Normen", - Fach.BRC: "BRC (Betriebswirtschaft)", - Fach.BVW: "BVW (Volkswirtschaft)", - Fach.ERNAEHRUNG: "Ernährung", - Fach.MECHATRONIK: "Mechatronik", - Fach.GESUNDHEIT_PFLEGE: "Gesundheit-Pflege", - Fach.PAEDAGOGIK_PSYCHOLOGIE: "Pädagogik-Psychologie", -} - -DOKUMENT_TYP_LABELS = { - DokumentTyp.AUFGABE: "Aufgabe", - DokumentTyp.ERWARTUNGSHORIZONT: "Erwartungshorizont", - DokumentTyp.DECKBLATT: "Deckblatt", - DokumentTyp.MATERIAL: "Material", - DokumentTyp.HOERVERSTEHEN: "Hörverstehen", - DokumentTyp.SPRACHMITTLUNG: "Sprachmittlung", - DokumentTyp.BEWERTUNGSBOGEN: "Bewertungsbogen", -} +# Backward-compat shim -- module moved to abitur/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("abitur.models") diff --git a/backend-lehrer/abitur_docs_recognition.py b/backend-lehrer/abitur_docs_recognition.py index 69aae1b..7492ee7 100644 --- a/backend-lehrer/abitur_docs_recognition.py +++ b/backend-lehrer/abitur_docs_recognition.py @@ -1,124 +1,4 @@ -""" -Abitur Document Store - Dateinamen-Erkennung und Helfer. - -Erkennt Metadaten aus NiBiS-Dateinamen (Niedersachsen). -""" - -import re -from typing import Dict, Any -from pathlib import Path - -from abitur_docs_models import ( - Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus, - RecognitionResult, AbiturDokument, DokumentResponse, - FACH_NAME_MAPPING, -) - - -def parse_nibis_filename(filename: str) -> RecognitionResult: - """ - Erkennt Metadaten aus NiBiS-Dateinamen. - - Beispiele: - - 2025_Deutsch_eA_I.pdf - - 2025_Deutsch_eA_I_EWH.pdf - - 2025_Biologie_gA_1.pdf - - 2025_Englisch_eA_HV.pdf (Hörverstehen) - """ - result = RecognitionResult( - success=False, - bundesland=Bundesland.NIEDERSACHSEN, - fach=None, - jahr=None, - niveau=None, - typ=None, - aufgaben_nummer=None, - confidence=0.0, - raw_filename=filename, - suggestions=[] - ) - - # Bereinige Dateiname - name = Path(filename).stem.lower() - - # Extrahiere Jahr (4 Ziffern am Anfang) - jahr_match = re.match(r'^(\d{4})', name) - if jahr_match: - result.jahr = int(jahr_match.group(1)) - result.confidence += 0.2 - - # Extrahiere Fach - for fach_key, fach_enum in FACH_NAME_MAPPING.items(): - if fach_key in name.replace("_", "").replace("-", ""): - result.fach = fach_enum - result.confidence += 0.3 - break - - # Extrahiere Niveau (eA/gA) - if "_ea" in name or "_ea_" in name or "ea_" in name: - result.niveau = Niveau.EA - result.confidence += 0.2 - elif "_ga" in name or "_ga_" in name or "ga_" in name: - result.niveau = Niveau.GA - result.confidence += 0.2 - - # Extrahiere Typ - if "_ewh" in name: - result.typ = DokumentTyp.ERWARTUNGSHORIZONT - result.confidence += 0.2 - elif "_hv" in name or "hoerverstehen" in name: - result.typ = DokumentTyp.HOERVERSTEHEN - result.confidence += 0.15 - elif "_sm" in name or "_me" in name or "sprachmittlung" in name: - result.typ = DokumentTyp.SPRACHMITTLUNG - result.confidence += 0.15 - elif "deckblatt" in name: - result.typ = DokumentTyp.DECKBLATT - result.confidence += 0.15 - elif "material" in name: - result.typ = DokumentTyp.MATERIAL - result.confidence += 0.15 - elif "bewertung" in name: - result.typ = DokumentTyp.BEWERTUNGSBOGEN - result.confidence += 0.15 - else: - result.typ = DokumentTyp.AUFGABE - result.confidence += 0.1 - - # Extrahiere Aufgabennummer (römisch oder arabisch) - aufgabe_match = re.search(r'_([ivx]+|[1-4][abc]?)(?:_|\.pdf|$)', name, re.IGNORECASE) - if aufgabe_match: - result.aufgaben_nummer = aufgabe_match.group(1).upper() - result.confidence += 0.1 - - # Erfolg wenn mindestens Fach und Jahr erkannt - if result.fach and result.jahr: - result.success = True - - # Normalisiere Confidence auf max 1.0 - result.confidence = min(result.confidence, 1.0) - - return result - - -def to_dokument_response(doc: AbiturDokument) -> DokumentResponse: - """Konvertiert internes Dokument zu Response.""" - return DokumentResponse( - id=doc.id, - dateiname=doc.dateiname, - original_dateiname=doc.original_dateiname, - bundesland=doc.bundesland, - fach=doc.fach, - jahr=doc.jahr, - niveau=doc.niveau, - typ=doc.typ, - aufgaben_nummer=doc.aufgaben_nummer, - status=doc.status, - confidence=doc.confidence, - file_path=doc.file_path, - file_size=doc.file_size, - indexed=doc.indexed, - vector_ids=doc.vector_ids, - created_at=doc.created_at, - updated_at=doc.updated_at - ) +# Backward-compat shim -- module moved to abitur/recognition.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("abitur.recognition") diff --git a/backend-lehrer/certificates_api.py b/backend-lehrer/certificates_api.py index fb1f999..4aec5c9 100644 --- a/backend-lehrer/certificates_api.py +++ b/backend-lehrer/certificates_api.py @@ -1,340 +1,4 @@ -""" -Certificates API - Zeugnisverwaltung fuer BreakPilot. - -Split into: -- certificates_models.py: Enums, Pydantic models, helper functions -- certificates_api.py (this file): API endpoints and in-memory store -""" - -import logging -import uuid -from datetime import datetime -from typing import Optional, Dict, List, Any - -from fastapi import APIRouter, HTTPException, Response, Query - -# PDF service requires WeasyPrint with system libraries - make optional for CI -try: - from services.pdf_service import generate_certificate_pdf, SchoolInfo - _pdf_available = True -except (ImportError, OSError): - generate_certificate_pdf = None # type: ignore - SchoolInfo = None # type: ignore - _pdf_available = False - -from certificates_models import ( - CertificateType, - CertificateStatus, - BehaviorGrade, - CertificateCreateRequest, - CertificateUpdateRequest, - CertificateResponse, - CertificateListResponse, - GradeStatistics, - get_type_label as _get_type_label, - calculate_average as _calculate_average, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/certificates", tags=["certificates"]) - - -# ============================================================================= -# In-Memory Storage (Prototyp - spaeter durch DB ersetzen) -# ============================================================================= - -_certificates_store: Dict[str, Dict[str, Any]] = {} - - -def _get_certificate(cert_id: str) -> Dict[str, Any]: - """Holt Zeugnis aus dem Store.""" - if cert_id not in _certificates_store: - raise HTTPException(status_code=404, detail=f"Zeugnis mit ID {cert_id} nicht gefunden") - return _certificates_store[cert_id] - - -def _save_certificate(cert_data: Dict[str, Any]) -> str: - """Speichert Zeugnis und gibt ID zurueck.""" - cert_id = cert_data.get("id") or str(uuid.uuid4()) - cert_data["id"] = cert_id - cert_data["updated_at"] = datetime.now() - if "created_at" not in cert_data: - cert_data["created_at"] = datetime.now() - _certificates_store[cert_id] = cert_data - return cert_id - - -# ============================================================================= -# API Endpoints -# ============================================================================= - -@router.post("/", response_model=CertificateResponse) -async def create_certificate(request: CertificateCreateRequest): - """Erstellt ein neues Zeugnis.""" - logger.info(f"Creating new certificate for student: {request.student_name}") - - subjects_list = [s.model_dump() for s in request.subjects] - - cert_data = { - "student_id": request.student_id, - "student_name": request.student_name, - "student_birthdate": request.student_birthdate, - "student_class": request.student_class, - "school_year": request.school_year, - "certificate_type": request.certificate_type, - "subjects": subjects_list, - "attendance": request.attendance.model_dump(), - "remarks": request.remarks, - "class_teacher": request.class_teacher, - "principal": request.principal, - "school_info": request.school_info.model_dump() if request.school_info else None, - "issue_date": request.issue_date or datetime.now().strftime("%d.%m.%Y"), - "social_behavior": request.social_behavior, - "work_behavior": request.work_behavior, - "status": CertificateStatus.DRAFT, - "average_grade": _calculate_average(subjects_list), - "pdf_path": None, - "dsms_cid": None, - } - - cert_id = _save_certificate(cert_data) - cert_data["id"] = cert_id - logger.info(f"Certificate created with ID: {cert_id}") - return CertificateResponse(**cert_data) - - -# IMPORTANT: Static routes must be defined BEFORE dynamic /{cert_id} route -@router.get("/types") -async def get_certificate_types(): - """Gibt alle verfuegbaren Zeugnistypen zurueck.""" - return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in CertificateType]} - - -@router.get("/behavior-grades") -async def get_behavior_grades(): - """Gibt alle verfuegbaren Verhaltensnoten zurueck.""" - labels = { - BehaviorGrade.A: "A - Sehr gut", BehaviorGrade.B: "B - Gut", - BehaviorGrade.C: "C - Befriedigend", BehaviorGrade.D: "D - Verbesserungswuerdig" - } - return {"grades": [{"value": g.value, "label": labels[g]} for g in BehaviorGrade]} - - -@router.get("/{cert_id}", response_model=CertificateResponse) -async def get_certificate(cert_id: str): - """Laedt ein gespeichertes Zeugnis.""" - logger.info(f"Getting certificate: {cert_id}") - return CertificateResponse(**_get_certificate(cert_id)) - - -@router.get("/", response_model=CertificateListResponse) -async def list_certificates( - student_id: Optional[str] = Query(None), - class_name: Optional[str] = Query(None), - school_year: Optional[str] = Query(None), - certificate_type: Optional[CertificateType] = Query(None), - status: Optional[CertificateStatus] = Query(None), - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100) -): - """Listet alle gespeicherten Zeugnisse mit optionalen Filtern.""" - logger.info("Listing certificates with filters") - - filtered_certs = list(_certificates_store.values()) - if student_id: - filtered_certs = [c for c in filtered_certs if c.get("student_id") == student_id] - if class_name: - filtered_certs = [c for c in filtered_certs if c.get("student_class") == class_name] - if school_year: - filtered_certs = [c for c in filtered_certs if c.get("school_year") == school_year] - if certificate_type: - filtered_certs = [c for c in filtered_certs if c.get("certificate_type") == certificate_type] - if status: - filtered_certs = [c for c in filtered_certs if c.get("status") == status] - - filtered_certs.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) - total = len(filtered_certs) - start = (page - 1) * page_size - paginated_certs = filtered_certs[start:start + page_size] - - return CertificateListResponse( - certificates=[CertificateResponse(**c) for c in paginated_certs], - total=total, page=page, page_size=page_size - ) - - -@router.put("/{cert_id}", response_model=CertificateResponse) -async def update_certificate(cert_id: str, request: CertificateUpdateRequest): - """Aktualisiert ein bestehendes Zeugnis.""" - logger.info(f"Updating certificate: {cert_id}") - cert_data = _get_certificate(cert_id) - - if cert_data.get("status") in [CertificateStatus.ISSUED, CertificateStatus.ARCHIVED]: - raise HTTPException(status_code=400, detail="Zeugnis wurde bereits ausgestellt und kann nicht mehr bearbeitet werden") - - update_data = request.model_dump(exclude_unset=True) - for key, value in update_data.items(): - if value is not None: - if key == "subjects": - cert_data[key] = [s if isinstance(s, dict) else s.model_dump() for s in value] - cert_data["average_grade"] = _calculate_average(cert_data["subjects"]) - elif key == "attendance": - cert_data[key] = value if isinstance(value, dict) else value.model_dump() - else: - cert_data[key] = value - - _save_certificate(cert_data) - return CertificateResponse(**cert_data) - - -@router.delete("/{cert_id}") -async def delete_certificate(cert_id: str): - """Loescht ein Zeugnis. Nur Entwuerfe koennen geloescht werden.""" - logger.info(f"Deleting certificate: {cert_id}") - cert_data = _get_certificate(cert_id) - if cert_data.get("status") != CertificateStatus.DRAFT: - raise HTTPException(status_code=400, detail="Nur Zeugnis-Entwuerfe koennen geloescht werden") - del _certificates_store[cert_id] - return {"message": f"Zeugnis {cert_id} wurde geloescht"} - - -@router.post("/{cert_id}/export-pdf") -async def export_certificate_pdf(cert_id: str): - """Exportiert ein Zeugnis als PDF.""" - logger.info(f"Exporting certificate {cert_id} as PDF") - cert_data = _get_certificate(cert_id) - - try: - pdf_bytes = generate_certificate_pdf(cert_data) - except Exception as e: - logger.error(f"Error generating PDF: {e}") - raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}") - - student_name = cert_data.get("student_name", "Zeugnis").replace(" ", "_") - school_year = cert_data.get("school_year", "").replace("/", "-") - cert_type = cert_data.get("certificate_type", "zeugnis") - filename = f"Zeugnis_{student_name}_{cert_type}_{school_year}.pdf" - - from urllib.parse import quote - filename_ascii = filename.encode('ascii', 'replace').decode('ascii') - filename_encoded = quote(filename, safe='') - - return Response( - content=pdf_bytes, media_type="application/pdf", - headers={ - "Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}", - "Content-Length": str(len(pdf_bytes)) - } - ) - - -@router.post("/{cert_id}/submit-review") -async def submit_for_review(cert_id: str): - """Reicht Zeugnis zur Pruefung ein.""" - logger.info(f"Submitting certificate {cert_id} for review") - cert_data = _get_certificate(cert_id) - if cert_data.get("status") != CertificateStatus.DRAFT: - raise HTTPException(status_code=400, detail="Nur Entwuerfe koennen zur Pruefung eingereicht werden") - if not cert_data.get("subjects"): - raise HTTPException(status_code=400, detail="Keine Fachnoten eingetragen") - cert_data["status"] = CertificateStatus.REVIEW - _save_certificate(cert_data) - return {"message": "Zeugnis wurde zur Pruefung eingereicht", "status": CertificateStatus.REVIEW} - - -@router.post("/{cert_id}/approve") -async def approve_certificate(cert_id: str): - """Genehmigt ein Zeugnis.""" - logger.info(f"Approving certificate {cert_id}") - cert_data = _get_certificate(cert_id) - if cert_data.get("status") != CertificateStatus.REVIEW: - raise HTTPException(status_code=400, detail="Nur Zeugnisse in Pruefung koennen genehmigt werden") - cert_data["status"] = CertificateStatus.APPROVED - _save_certificate(cert_data) - return {"message": "Zeugnis wurde genehmigt", "status": CertificateStatus.APPROVED} - - -@router.post("/{cert_id}/issue") -async def issue_certificate(cert_id: str): - """Stellt ein Zeugnis offiziell aus.""" - logger.info(f"Issuing certificate {cert_id}") - cert_data = _get_certificate(cert_id) - if cert_data.get("status") != CertificateStatus.APPROVED: - raise HTTPException(status_code=400, detail="Nur genehmigte Zeugnisse koennen ausgestellt werden") - cert_data["status"] = CertificateStatus.ISSUED - cert_data["issue_date"] = datetime.now().strftime("%d.%m.%Y") - _save_certificate(cert_data) - return {"message": "Zeugnis wurde ausgestellt", "status": CertificateStatus.ISSUED, "issue_date": cert_data["issue_date"]} - - -@router.get("/student/{student_id}", response_model=CertificateListResponse) -async def get_certificates_for_student( - student_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100) -): - """Laedt alle Zeugnisse fuer einen bestimmten Schueler.""" - logger.info(f"Getting certificates for student: {student_id}") - filtered_certs = [c for c in _certificates_store.values() if c.get("student_id") == student_id] - filtered_certs.sort(key=lambda x: (x.get("school_year", ""), x.get("certificate_type", "")), reverse=True) - total = len(filtered_certs) - start = (page - 1) * page_size - paginated_certs = filtered_certs[start:start + page_size] - return CertificateListResponse( - certificates=[CertificateResponse(**c) for c in paginated_certs], - total=total, page=page, page_size=page_size - ) - - -@router.get("/class/{class_name}/statistics", response_model=GradeStatistics) -async def get_class_statistics( - class_name: str, - school_year: str = Query(..., description="Schuljahr"), - certificate_type: CertificateType = Query(CertificateType.HALBJAHR) -): - """Berechnet Notenstatistiken fuer eine Klasse.""" - logger.info(f"Calculating statistics for class {class_name}") - - class_certs = [ - c for c in _certificates_store.values() - if c.get("student_class") == class_name - and c.get("school_year") == school_year - and c.get("certificate_type") == certificate_type - ] - - if not class_certs: - raise HTTPException(status_code=404, detail=f"Keine Zeugnisse fuer Klasse {class_name} im Schuljahr {school_year} gefunden") - - all_grades: List[float] = [] - subject_grades: Dict[str, List[float]] = {} - grade_counts = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0} - - for cert in class_certs: - avg = cert.get("average_grade") - if avg: - all_grades.append(avg) - rounded = str(round(avg)) - if rounded in grade_counts: - grade_counts[rounded] += 1 - - for subject in cert.get("subjects", []): - name = subject.get("name") - grade_str = subject.get("grade") - try: - grade = float(grade_str) - if name not in subject_grades: - subject_grades[name] = [] - subject_grades[name].append(grade) - except (ValueError, TypeError): - pass - - subject_averages = { - name: round(sum(grades) / len(grades), 2) - for name, grades in subject_grades.items() if grades - } - - return GradeStatistics( - class_name=class_name, school_year=school_year, - certificate_type=certificate_type, student_count=len(class_certs), - average_grade=round(sum(all_grades) / len(all_grades), 2) if all_grades else 0.0, - grade_distribution=grade_counts, subject_averages=subject_averages - ) +# Backward-compat shim -- module moved to letters/certificates_api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("letters.certificates_api") diff --git a/backend-lehrer/certificates_models.py b/backend-lehrer/certificates_models.py index 08a8c41..0cc5be4 100644 --- a/backend-lehrer/certificates_models.py +++ b/backend-lehrer/certificates_models.py @@ -1,184 +1,4 @@ -""" -Certificates Models - Pydantic models and enums for Zeugnisverwaltung. -""" -from datetime import datetime -from typing import Optional, List, Dict -from enum import Enum - -from pydantic import BaseModel, Field - - -# ============================================================================= -# Enums -# ============================================================================= - -class CertificateType(str, Enum): - """Typen von Zeugnissen.""" - HALBJAHR = "halbjahr" - JAHRES = "jahres" - ABSCHLUSS = "abschluss" - ABGANG = "abgang" - UEBERGANG = "uebergang" - - -class CertificateStatus(str, Enum): - """Status eines Zeugnisses.""" - DRAFT = "draft" - REVIEW = "review" - APPROVED = "approved" - ISSUED = "issued" - ARCHIVED = "archived" - - -class GradeType(str, Enum): - """Notentyp.""" - NUMERIC = "numeric" - POINTS = "points" - TEXT = "text" - - -class BehaviorGrade(str, Enum): - """Verhaltens-/Arbeitsnoten.""" - A = "A" - B = "B" - C = "C" - D = "D" - - -# ============================================================================= -# Pydantic Models -# ============================================================================= - -class SchoolInfoModel(BaseModel): - """Schulinformationen fuer Zeugnis.""" - name: str - address: str - phone: str - email: str - website: Optional[str] = None - principal: Optional[str] = None - logo_path: Optional[str] = None - - -class SubjectGrade(BaseModel): - """Note fuer ein Fach.""" - name: str = Field(..., description="Fachname") - grade: str = Field(..., description="Note (1-6 oder A-D)") - points: Optional[int] = Field(None, description="Punkte (Oberstufe, 0-15)") - note: Optional[str] = Field(None, description="Bemerkung zum Fach") - - -class AttendanceInfo(BaseModel): - """Anwesenheitsinformationen.""" - days_absent: int = Field(0, description="Fehlende Tage gesamt") - days_excused: int = Field(0, description="Entschuldigte Tage") - days_unexcused: int = Field(0, description="Unentschuldigte Tage") - hours_absent: Optional[int] = Field(None, description="Fehlstunden gesamt") - - -class CertificateCreateRequest(BaseModel): - """Request zum Erstellen eines neuen Zeugnisses.""" - student_id: str = Field(..., description="ID des Schuelers") - student_name: str = Field(..., description="Name des Schuelers") - student_birthdate: str = Field(..., description="Geburtsdatum") - student_class: str = Field(..., description="Klasse") - school_year: str = Field(..., description="Schuljahr (z.B. '2024/2025')") - certificate_type: CertificateType = Field(..., description="Art des Zeugnisses") - subjects: List[SubjectGrade] = Field(..., description="Fachnoten") - attendance: AttendanceInfo = Field(default_factory=AttendanceInfo) - remarks: Optional[str] = Field(None, description="Bemerkungen") - class_teacher: str = Field(..., description="Klassenlehrer/in") - principal: str = Field(..., description="Schulleiter/in") - school_info: Optional[SchoolInfoModel] = Field(None) - issue_date: Optional[str] = Field(None, description="Ausstellungsdatum") - social_behavior: Optional[BehaviorGrade] = Field(None) - work_behavior: Optional[BehaviorGrade] = Field(None) - - -class CertificateUpdateRequest(BaseModel): - """Request zum Aktualisieren eines Zeugnisses.""" - subjects: Optional[List[SubjectGrade]] = None - attendance: Optional[AttendanceInfo] = None - remarks: Optional[str] = None - class_teacher: Optional[str] = None - principal: Optional[str] = None - social_behavior: Optional[BehaviorGrade] = None - work_behavior: Optional[BehaviorGrade] = None - status: Optional[CertificateStatus] = None - - -class CertificateResponse(BaseModel): - """Response mit Zeugnisdaten.""" - id: str - student_id: str - student_name: str - student_birthdate: str - student_class: str - school_year: str - certificate_type: CertificateType - subjects: List[SubjectGrade] - attendance: AttendanceInfo - remarks: Optional[str] - class_teacher: str - principal: str - school_info: Optional[SchoolInfoModel] - issue_date: Optional[str] - social_behavior: Optional[BehaviorGrade] - work_behavior: Optional[BehaviorGrade] - status: CertificateStatus - average_grade: Optional[float] - pdf_path: Optional[str] - dsms_cid: Optional[str] - created_at: datetime - updated_at: datetime - - -class CertificateListResponse(BaseModel): - """Response mit Liste von Zeugnissen.""" - certificates: List[CertificateResponse] - total: int - page: int - page_size: int - - -class GradeStatistics(BaseModel): - """Notenstatistiken fuer eine Klasse.""" - class_name: str - school_year: str - certificate_type: CertificateType - student_count: int - average_grade: float - grade_distribution: Dict[str, int] - subject_averages: Dict[str, float] - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def get_type_label(cert_type: CertificateType) -> str: - """Gibt menschenlesbare Labels fuer Zeugnistypen zurueck.""" - labels = { - CertificateType.HALBJAHR: "Halbjahreszeugnis", - CertificateType.JAHRES: "Jahreszeugnis", - CertificateType.ABSCHLUSS: "Abschlusszeugnis", - CertificateType.ABGANG: "Abgangszeugnis", - CertificateType.UEBERGANG: "Uebergangszeugnis", - } - return labels.get(cert_type, cert_type.value) - - -def calculate_average(subjects: List[Dict]) -> Optional[float]: - """Berechnet Notendurchschnitt.""" - numeric_grades = [] - for subject in subjects: - grade = subject.get("grade", "") - try: - numeric = float(grade) - if 1 <= numeric <= 6: - numeric_grades.append(numeric) - except (ValueError, TypeError): - pass - if numeric_grades: - return round(sum(numeric_grades) / len(numeric_grades), 2) - return None +# Backward-compat shim -- module moved to letters/certificates_models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("letters.certificates_models") diff --git a/backend-lehrer/correction/__init__.py b/backend-lehrer/correction/__init__.py new file mode 100644 index 0000000..7464c27 --- /dev/null +++ b/backend-lehrer/correction/__init__.py @@ -0,0 +1 @@ +# correction — Klassenarbeits-Korrektur (grading, feedback, OCR). diff --git a/backend-lehrer/correction/api.py b/backend-lehrer/correction/api.py new file mode 100644 index 0000000..459a939 --- /dev/null +++ b/backend-lehrer/correction/api.py @@ -0,0 +1,23 @@ +""" +Correction API - REST API fuer Klassenarbeits-Korrektur. + +Barrel re-export: router and all public symbols. +""" + +from .endpoints import router # noqa: F401 +from .models import ( # noqa: F401 + CorrectionStatus, + AnswerEvaluation, + CorrectionCreate, + CorrectionUpdate, + Correction, + CorrectionResponse, + OCRResponse, + AnalysisResponse, +) +from .helpers import ( # noqa: F401 + corrections_store, + calculate_grade, + generate_ai_feedback, + process_ocr, +) diff --git a/backend-lehrer/correction/endpoints.py b/backend-lehrer/correction/endpoints.py new file mode 100644 index 0000000..ce8b0f0 --- /dev/null +++ b/backend-lehrer/correction/endpoints.py @@ -0,0 +1,474 @@ +""" +Correction API - REST endpoint handlers. + +Workflow: +1. Upload: Gescannte Klassenarbeit hochladen +2. OCR: Text aus Handschrift extrahieren +3. Analyse: Antworten analysieren und bewerten +4. Feedback: KI-generiertes Feedback erstellen +5. Export: Korrigierte Arbeit als PDF exportieren +""" + +import logging +import uuid +import os +from datetime import datetime +from typing import Dict, Any, Optional +from pathlib import Path + +from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks + +from .models import ( + CorrectionStatus, + AnswerEvaluation, + CorrectionCreate, + CorrectionUpdate, + Correction, + CorrectionResponse, + AnalysisResponse, + UPLOAD_DIR, +) +from .helpers import ( + corrections_store, + calculate_grade, + generate_ai_feedback, + process_ocr, + PDFService, + CorrectionData, + StudentInfo, +) + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/corrections", + tags=["corrections"], +) + + +# ============================================================================ +# API Endpoints +# ============================================================================ + +@router.post("/", response_model=CorrectionResponse) +async def create_correction(data: CorrectionCreate): + """ + Erstellt eine neue Korrektur. + + Noch ohne Datei - diese wird separat hochgeladen. + """ + correction_id = str(uuid.uuid4()) + now = datetime.utcnow() + + correction = Correction( + id=correction_id, + student_id=data.student_id, + student_name=data.student_name, + class_name=data.class_name, + exam_title=data.exam_title, + subject=data.subject, + max_points=data.max_points, + status=CorrectionStatus.UPLOADED, + created_at=now, + updated_at=now + ) + + corrections_store[correction_id] = correction + logger.info(f"Created correction {correction_id} for {data.student_name}") + + return CorrectionResponse(success=True, correction=correction) + + +@router.post("/{correction_id}/upload", response_model=CorrectionResponse) +async def upload_exam( + correction_id: str, + background_tasks: BackgroundTasks, + file: UploadFile = File(...) +): + """ + Laedt gescannte Klassenarbeit hoch und startet OCR. + + Unterstuetzte Formate: PDF, PNG, JPG, JPEG + """ + correction = corrections_store.get(correction_id) + if not correction: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + # Validiere Dateiformat + allowed_extensions = {".pdf", ".png", ".jpg", ".jpeg"} + file_ext = Path(file.filename).suffix.lower() if file.filename else "" + + if file_ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"Ungueltiges Dateiformat. Erlaubt: {', '.join(allowed_extensions)}" + ) + + # Speichere Datei + file_path = UPLOAD_DIR / f"{correction_id}{file_ext}" + + try: + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + correction.file_path = str(file_path) + correction.updated_at = datetime.utcnow() + corrections_store[correction_id] = correction + + # Starte OCR im Hintergrund + background_tasks.add_task(process_ocr, correction_id, str(file_path)) + + logger.info(f"Uploaded file for correction {correction_id}: {file.filename}") + + return CorrectionResponse(success=True, correction=correction) + + except Exception as e: + logger.error(f"Upload error: {e}") + return CorrectionResponse(success=False, error=str(e)) + + +@router.get("/{correction_id}", response_model=CorrectionResponse) +async def get_correction(correction_id: str): + """Ruft eine Korrektur ab.""" + correction = corrections_store.get(correction_id) + if not correction: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + return CorrectionResponse(success=True, correction=correction) + + +@router.get("/", response_model=Dict[str, Any]) +async def list_corrections( + class_name: Optional[str] = None, + status: Optional[CorrectionStatus] = None, + limit: int = 50 +): + """Listet Korrekturen auf, optional gefiltert.""" + corrections = list(corrections_store.values()) + + if class_name: + corrections = [c for c in corrections if c.class_name == class_name] + + if status: + corrections = [c for c in corrections if c.status == status] + + # Sortiere nach Erstellungsdatum (neueste zuerst) + corrections.sort(key=lambda x: x.created_at, reverse=True) + + return { + "total": len(corrections), + "corrections": [c.dict() for c in corrections[:limit]] + } + + +@router.post("/{correction_id}/analyze", response_model=AnalysisResponse) +async def analyze_correction( + correction_id: str, + expected_answers: Optional[Dict[str, str]] = None +): + """ + Analysiert die extrahierten Antworten. + + Optional mit Musterloesung fuer automatische Bewertung. + """ + correction = corrections_store.get(correction_id) + if not correction: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + if correction.status not in [CorrectionStatus.OCR_COMPLETE, CorrectionStatus.ANALYZED]: + raise HTTPException( + status_code=400, + detail=f"Korrektur im falschen Status: {correction.status}" + ) + + if not correction.extracted_text: + raise HTTPException(status_code=400, detail="Kein extrahierter Text vorhanden") + + try: + correction.status = CorrectionStatus.ANALYZING + corrections_store[correction_id] = correction + + # Einfache Analyse ohne LLM + # Teile Text in Abschnitte (simuliert Aufgabenerkennung) + text_parts = correction.extracted_text.split('\n\n') + evaluations = [] + + for i, part in enumerate(text_parts[:10], start=1): # Max 10 Aufgaben + if len(part.strip()) < 5: + continue + + # Simulierte Bewertung + # In Produktion wuerde hier LLM-basierte Analyse stattfinden + expected = expected_answers.get(str(i), "") if expected_answers else "" + + # Einfacher Textvergleich (in Produktion: semantischer Vergleich) + is_correct = bool(expected and expected.lower() in part.lower()) + points = correction.max_points / len(text_parts) if text_parts else 0 + + evaluation = AnswerEvaluation( + question_number=i, + extracted_text=part[:200], # Kuerzen fuer Response + points_possible=points, + points_awarded=points if is_correct else points * 0.5, # Teilpunkte + feedback=f"Antwort zu Aufgabe {i}" + (" korrekt." if is_correct else " mit Verbesserungsbedarf."), + is_correct=is_correct, + confidence=0.7 # Simulierte Confidence + ) + evaluations.append(evaluation) + + # Berechne Gesamtergebnis + total_points = sum(e.points_awarded for e in evaluations) + percentage = (total_points / correction.max_points * 100) if correction.max_points > 0 else 0 + suggested_grade = calculate_grade(percentage) + + # Generiere Feedback + ai_feedback = generate_ai_feedback( + evaluations, total_points, correction.max_points, correction.subject + ) + + # Aktualisiere Korrektur + correction.evaluations = evaluations + correction.total_points = total_points + correction.percentage = percentage + correction.grade = suggested_grade + correction.ai_feedback = ai_feedback + correction.status = CorrectionStatus.ANALYZED + correction.updated_at = datetime.utcnow() + corrections_store[correction_id] = correction + + logger.info(f"Analysis complete for {correction_id}: {total_points}/{correction.max_points}") + + return AnalysisResponse( + success=True, + evaluations=evaluations, + total_points=total_points, + percentage=percentage, + suggested_grade=suggested_grade, + ai_feedback=ai_feedback + ) + + except Exception as e: + logger.error(f"Analysis error: {e}") + correction.status = CorrectionStatus.ERROR + corrections_store[correction_id] = correction + return AnalysisResponse(success=False, error=str(e)) + + +@router.put("/{correction_id}", response_model=CorrectionResponse) +async def update_correction(correction_id: str, data: CorrectionUpdate): + """ + Aktualisiert eine Korrektur. + + Ermoeglicht manuelle Anpassungen durch die Lehrkraft. + """ + correction = corrections_store.get(correction_id) + if not correction: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + if data.evaluations is not None: + correction.evaluations = data.evaluations + correction.total_points = sum(e.points_awarded for e in data.evaluations) + correction.percentage = ( + correction.total_points / correction.max_points * 100 + ) if correction.max_points > 0 else 0 + + if data.total_points is not None: + correction.total_points = data.total_points + correction.percentage = ( + data.total_points / correction.max_points * 100 + ) if correction.max_points > 0 else 0 + + if data.grade is not None: + correction.grade = data.grade + + if data.teacher_notes is not None: + correction.teacher_notes = data.teacher_notes + + if data.status is not None: + correction.status = data.status + + correction.updated_at = datetime.utcnow() + corrections_store[correction_id] = correction + + return CorrectionResponse(success=True, correction=correction) + + +@router.post("/{correction_id}/complete", response_model=CorrectionResponse) +async def complete_correction(correction_id: str): + """Markiert Korrektur als abgeschlossen.""" + correction = corrections_store.get(correction_id) + if not correction: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + correction.status = CorrectionStatus.COMPLETED + correction.updated_at = datetime.utcnow() + corrections_store[correction_id] = correction + + logger.info(f"Correction {correction_id} completed: {correction.grade}") + + return CorrectionResponse(success=True, correction=correction) + + +@router.get("/{correction_id}/export-pdf") +async def export_correction_pdf(correction_id: str): + """ + Exportiert korrigierte Arbeit als PDF. + + Enthaelt: + - Originalscan + - Bewertungen + - Feedback + - Gesamtergebnis + """ + correction = corrections_store.get(correction_id) + if not correction: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + try: + pdf_service = PDFService() + + # Erstelle CorrectionData + correction_data = CorrectionData( + student=StudentInfo( + student_id=correction.student_id, + name=correction.student_name, + class_name=correction.class_name + ), + exam_title=correction.exam_title, + subject=correction.subject, + date=correction.created_at.strftime("%d.%m.%Y"), + max_points=correction.max_points, + achieved_points=correction.total_points, + grade=correction.grade or "", + percentage=correction.percentage, + corrections=[ + { + "question": f"Aufgabe {e.question_number}", + "answer": e.extracted_text, + "points": f"{e.points_awarded}/{e.points_possible}", + "feedback": e.feedback + } + for e in correction.evaluations + ], + teacher_notes=correction.teacher_notes or "", + ai_feedback=correction.ai_feedback or "" + ) + + # Generiere PDF + pdf_bytes = pdf_service.generate_correction_pdf(correction_data) + + from fastapi.responses import Response + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="korrektur_{correction.student_name}_{correction.exam_title}.pdf"' + } + ) + + except Exception as e: + logger.error(f"PDF export error: {e}") + raise HTTPException(status_code=500, detail=f"PDF-Export fehlgeschlagen: {str(e)}") + + +@router.delete("/{correction_id}") +async def delete_correction(correction_id: str): + """Loescht eine Korrektur.""" + if correction_id not in corrections_store: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + correction = corrections_store[correction_id] + + # Loesche auch die hochgeladene Datei + if correction.file_path and os.path.exists(correction.file_path): + try: + os.remove(correction.file_path) + except Exception as e: + logger.warning(f"Could not delete file {correction.file_path}: {e}") + + del corrections_store[correction_id] + logger.info(f"Deleted correction {correction_id}") + + return {"status": "deleted", "id": correction_id} + + +@router.get("/class/{class_name}/summary") +async def get_class_summary(class_name: str): + """ + Gibt Zusammenfassung fuer eine Klasse zurueck. + + Enthaelt Statistiken ueber alle Korrekturen der Klasse. + """ + class_corrections = [ + c for c in corrections_store.values() + if c.class_name == class_name and c.status == CorrectionStatus.COMPLETED + ] + + if not class_corrections: + return { + "class_name": class_name, + "total_students": 0, + "average_percentage": 0, + "grade_distribution": {}, + "corrections": [] + } + + # Berechne Statistiken + percentages = [c.percentage for c in class_corrections] + average_percentage = sum(percentages) / len(percentages) if percentages else 0 + + # Notenverteilung + grade_distribution = {} + for c in class_corrections: + grade = c.grade or "?" + grade_distribution[grade] = grade_distribution.get(grade, 0) + 1 + + return { + "class_name": class_name, + "total_students": len(class_corrections), + "average_percentage": round(average_percentage, 1), + "average_points": round( + sum(c.total_points for c in class_corrections) / len(class_corrections), 1 + ), + "grade_distribution": grade_distribution, + "corrections": [ + { + "id": c.id, + "student_name": c.student_name, + "total_points": c.total_points, + "percentage": c.percentage, + "grade": c.grade + } + for c in sorted(class_corrections, key=lambda x: x.student_name) + ] + } + + +@router.post("/{correction_id}/ocr/retry", response_model=CorrectionResponse) +async def retry_ocr(correction_id: str, background_tasks: BackgroundTasks): + """ + Wiederholt OCR-Verarbeitung. + + Nuetzlich wenn erste Verarbeitung fehlgeschlagen ist. + """ + correction = corrections_store.get(correction_id) + if not correction: + raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") + + if not correction.file_path: + raise HTTPException(status_code=400, detail="Keine Datei vorhanden") + + if not os.path.exists(correction.file_path): + raise HTTPException(status_code=400, detail="Datei nicht mehr vorhanden") + + # Starte OCR erneut + correction.status = CorrectionStatus.UPLOADED + correction.extracted_text = None + correction.updated_at = datetime.utcnow() + corrections_store[correction_id] = correction + + background_tasks.add_task(process_ocr, correction_id, correction.file_path) + + return CorrectionResponse(success=True, correction=correction) diff --git a/backend-lehrer/correction/helpers.py b/backend-lehrer/correction/helpers.py new file mode 100644 index 0000000..8fad300 --- /dev/null +++ b/backend-lehrer/correction/helpers.py @@ -0,0 +1,134 @@ +""" +Correction API - Helper functions for grading, feedback, and OCR processing. +""" + +import logging +from typing import List, Dict + +from .models import AnswerEvaluation, CorrectionStatus, Correction + +logger = logging.getLogger(__name__) + +# FileProcessor requires OpenCV with libGL - make optional for CI +try: + from services.file_processor import FileProcessor, ProcessingResult + _ocr_available = True +except (ImportError, OSError): + FileProcessor = None # type: ignore + ProcessingResult = None # type: ignore + _ocr_available = False + +# PDF service requires WeasyPrint with system libraries - make optional for CI +try: + from services.pdf_service import PDFService, CorrectionData, StudentInfo + _pdf_available = True +except (ImportError, OSError): + PDFService = None # type: ignore + CorrectionData = None # type: ignore + StudentInfo = None # type: ignore + _pdf_available = False + + +# ============================================================================ +# In-Memory Storage (spaeter durch DB ersetzen) +# ============================================================================ + +corrections_store: Dict[str, Correction] = {} + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def calculate_grade(percentage: float) -> str: + """Berechnet Note aus Prozent (deutsches System).""" + if percentage >= 92: + return "1" + elif percentage >= 81: + return "2" + elif percentage >= 67: + return "3" + elif percentage >= 50: + return "4" + elif percentage >= 30: + return "5" + else: + return "6" + + +def generate_ai_feedback( + evaluations: List[AnswerEvaluation], + total_points: float, + max_points: float, + subject: str +) -> str: + """Generiert KI-Feedback basierend auf Bewertung.""" + # Ohne LLM: Einfaches Template-basiertes Feedback + percentage = (total_points / max_points * 100) if max_points > 0 else 0 + correct_count = sum(1 for e in evaluations if e.is_correct) + total_count = len(evaluations) + + if percentage >= 90: + intro = "Hervorragende Leistung!" + elif percentage >= 75: + intro = "Gute Arbeit!" + elif percentage >= 60: + intro = "Insgesamt eine solide Leistung." + elif percentage >= 50: + intro = "Die Arbeit zeigt Grundkenntnisse, aber es gibt Verbesserungsbedarf." + else: + intro = "Es sind deutliche Wissensluecken erkennbar." + + # Finde Verbesserungsbereiche + weak_areas = [e for e in evaluations if not e.is_correct] + strengths = [e for e in evaluations if e.is_correct and e.confidence > 0.8] + + feedback_parts = [intro] + + if strengths: + feedback_parts.append( + f"Besonders gut geloest: Aufgabe(n) {', '.join(str(s.question_number) for s in strengths[:3])}." + ) + + if weak_areas: + feedback_parts.append( + f"Uebungsbedarf bei: Aufgabe(n) {', '.join(str(w.question_number) for w in weak_areas[:3])}." + ) + + feedback_parts.append( + f"Ergebnis: {correct_count} von {total_count} Aufgaben korrekt ({percentage:.1f}%)." + ) + + return " ".join(feedback_parts) + + +async def process_ocr(correction_id: str, file_path: str): + """Background Task fuer OCR-Verarbeitung.""" + from datetime import datetime + + correction = corrections_store.get(correction_id) + if not correction: + return + + try: + correction.status = CorrectionStatus.PROCESSING + corrections_store[correction_id] = correction + + # OCR durchfuehren + processor = FileProcessor() + result = processor.process_file(file_path) + + if result.success and result.text: + correction.extracted_text = result.text + correction.status = CorrectionStatus.OCR_COMPLETE + else: + correction.status = CorrectionStatus.ERROR + + correction.updated_at = datetime.utcnow() + corrections_store[correction_id] = correction + + except Exception as e: + logger.error(f"OCR error for {correction_id}: {e}") + correction.status = CorrectionStatus.ERROR + correction.updated_at = datetime.utcnow() + corrections_store[correction_id] = correction diff --git a/backend-lehrer/correction/models.py b/backend-lehrer/correction/models.py new file mode 100644 index 0000000..010ef17 --- /dev/null +++ b/backend-lehrer/correction/models.py @@ -0,0 +1,111 @@ +""" +Correction API - Pydantic models and enums. +""" + +from datetime import datetime +from typing import List, Dict, Any, Optional +from enum import Enum +from pathlib import Path + +from pydantic import BaseModel, Field + + +# Upload directory +UPLOAD_DIR = Path("/tmp/corrections") +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + +# ============================================================================ +# Enums and Models +# ============================================================================ + +class CorrectionStatus(str, Enum): + """Status einer Korrektur.""" + UPLOADED = "uploaded" # Datei hochgeladen + PROCESSING = "processing" # OCR laeuft + OCR_COMPLETE = "ocr_complete" # OCR abgeschlossen + ANALYZING = "analyzing" # Analyse laeuft + ANALYZED = "analyzed" # Analyse abgeschlossen + REVIEWING = "reviewing" # Lehrkraft prueft + COMPLETED = "completed" # Korrektur abgeschlossen + ERROR = "error" # Fehler aufgetreten + + +class AnswerEvaluation(BaseModel): + """Bewertung einer einzelnen Antwort.""" + question_number: int + extracted_text: str + points_possible: float + points_awarded: float + feedback: str + is_correct: bool + confidence: float # 0-1, wie sicher die OCR/Analyse ist + + +class CorrectionCreate(BaseModel): + """Request zum Erstellen einer neuen Korrektur.""" + student_id: str + student_name: str + class_name: str + exam_title: str + subject: str + max_points: float = Field(default=100.0, ge=0) + expected_answers: Optional[Dict[str, str]] = None # Musterloesung + + +class CorrectionUpdate(BaseModel): + """Request zum Aktualisieren einer Korrektur.""" + evaluations: Optional[List[AnswerEvaluation]] = None + total_points: Optional[float] = None + grade: Optional[str] = None + teacher_notes: Optional[str] = None + status: Optional[CorrectionStatus] = None + + +class Correction(BaseModel): + """Eine Korrektur.""" + id: str + student_id: str + student_name: str + class_name: str + exam_title: str + subject: str + max_points: float + total_points: float = 0.0 + percentage: float = 0.0 + grade: Optional[str] = None + status: CorrectionStatus + file_path: Optional[str] = None + extracted_text: Optional[str] = None + evaluations: List[AnswerEvaluation] = [] + teacher_notes: Optional[str] = None + ai_feedback: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class CorrectionResponse(BaseModel): + """Response fuer eine Korrektur.""" + success: bool + correction: Optional[Correction] = None + error: Optional[str] = None + + +class OCRResponse(BaseModel): + """Response fuer OCR-Ergebnis.""" + success: bool + extracted_text: Optional[str] = None + regions: List[Dict[str, Any]] = [] + confidence: float = 0.0 + error: Optional[str] = None + + +class AnalysisResponse(BaseModel): + """Response fuer Analyse-Ergebnis.""" + success: bool + evaluations: List[AnswerEvaluation] = [] + total_points: float = 0.0 + percentage: float = 0.0 + suggested_grade: Optional[str] = None + ai_feedback: Optional[str] = None + error: Optional[str] = None diff --git a/backend-lehrer/correction_api.py b/backend-lehrer/correction_api.py index c5fc64b..c35f66b 100644 --- a/backend-lehrer/correction_api.py +++ b/backend-lehrer/correction_api.py @@ -1,23 +1,4 @@ -""" -Correction API - REST API fuer Klassenarbeits-Korrektur. - -Barrel re-export: router and all public symbols. -""" - -from correction_endpoints import router # noqa: F401 -from correction_models import ( # noqa: F401 - CorrectionStatus, - AnswerEvaluation, - CorrectionCreate, - CorrectionUpdate, - Correction, - CorrectionResponse, - OCRResponse, - AnalysisResponse, -) -from correction_helpers import ( # noqa: F401 - corrections_store, - calculate_grade, - generate_ai_feedback, - process_ocr, -) +# Backward-compat shim -- module moved to correction/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("correction.api") diff --git a/backend-lehrer/correction_endpoints.py b/backend-lehrer/correction_endpoints.py index 7d58dfa..2e878fe 100644 --- a/backend-lehrer/correction_endpoints.py +++ b/backend-lehrer/correction_endpoints.py @@ -1,474 +1,4 @@ -""" -Correction API - REST endpoint handlers. - -Workflow: -1. Upload: Gescannte Klassenarbeit hochladen -2. OCR: Text aus Handschrift extrahieren -3. Analyse: Antworten analysieren und bewerten -4. Feedback: KI-generiertes Feedback erstellen -5. Export: Korrigierte Arbeit als PDF exportieren -""" - -import logging -import uuid -import os -from datetime import datetime -from typing import Dict, Any, Optional -from pathlib import Path - -from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks - -from correction_models import ( - CorrectionStatus, - AnswerEvaluation, - CorrectionCreate, - CorrectionUpdate, - Correction, - CorrectionResponse, - AnalysisResponse, - UPLOAD_DIR, -) -from correction_helpers import ( - corrections_store, - calculate_grade, - generate_ai_feedback, - process_ocr, - PDFService, - CorrectionData, - StudentInfo, -) - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/corrections", - tags=["corrections"], -) - - -# ============================================================================ -# API Endpoints -# ============================================================================ - -@router.post("/", response_model=CorrectionResponse) -async def create_correction(data: CorrectionCreate): - """ - Erstellt eine neue Korrektur. - - Noch ohne Datei - diese wird separat hochgeladen. - """ - correction_id = str(uuid.uuid4()) - now = datetime.utcnow() - - correction = Correction( - id=correction_id, - student_id=data.student_id, - student_name=data.student_name, - class_name=data.class_name, - exam_title=data.exam_title, - subject=data.subject, - max_points=data.max_points, - status=CorrectionStatus.UPLOADED, - created_at=now, - updated_at=now - ) - - corrections_store[correction_id] = correction - logger.info(f"Created correction {correction_id} for {data.student_name}") - - return CorrectionResponse(success=True, correction=correction) - - -@router.post("/{correction_id}/upload", response_model=CorrectionResponse) -async def upload_exam( - correction_id: str, - background_tasks: BackgroundTasks, - file: UploadFile = File(...) -): - """ - Laedt gescannte Klassenarbeit hoch und startet OCR. - - Unterstuetzte Formate: PDF, PNG, JPG, JPEG - """ - correction = corrections_store.get(correction_id) - if not correction: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - # Validiere Dateiformat - allowed_extensions = {".pdf", ".png", ".jpg", ".jpeg"} - file_ext = Path(file.filename).suffix.lower() if file.filename else "" - - if file_ext not in allowed_extensions: - raise HTTPException( - status_code=400, - detail=f"Ungueltiges Dateiformat. Erlaubt: {', '.join(allowed_extensions)}" - ) - - # Speichere Datei - file_path = UPLOAD_DIR / f"{correction_id}{file_ext}" - - try: - content = await file.read() - with open(file_path, "wb") as f: - f.write(content) - - correction.file_path = str(file_path) - correction.updated_at = datetime.utcnow() - corrections_store[correction_id] = correction - - # Starte OCR im Hintergrund - background_tasks.add_task(process_ocr, correction_id, str(file_path)) - - logger.info(f"Uploaded file for correction {correction_id}: {file.filename}") - - return CorrectionResponse(success=True, correction=correction) - - except Exception as e: - logger.error(f"Upload error: {e}") - return CorrectionResponse(success=False, error=str(e)) - - -@router.get("/{correction_id}", response_model=CorrectionResponse) -async def get_correction(correction_id: str): - """Ruft eine Korrektur ab.""" - correction = corrections_store.get(correction_id) - if not correction: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - return CorrectionResponse(success=True, correction=correction) - - -@router.get("/", response_model=Dict[str, Any]) -async def list_corrections( - class_name: Optional[str] = None, - status: Optional[CorrectionStatus] = None, - limit: int = 50 -): - """Listet Korrekturen auf, optional gefiltert.""" - corrections = list(corrections_store.values()) - - if class_name: - corrections = [c for c in corrections if c.class_name == class_name] - - if status: - corrections = [c for c in corrections if c.status == status] - - # Sortiere nach Erstellungsdatum (neueste zuerst) - corrections.sort(key=lambda x: x.created_at, reverse=True) - - return { - "total": len(corrections), - "corrections": [c.dict() for c in corrections[:limit]] - } - - -@router.post("/{correction_id}/analyze", response_model=AnalysisResponse) -async def analyze_correction( - correction_id: str, - expected_answers: Optional[Dict[str, str]] = None -): - """ - Analysiert die extrahierten Antworten. - - Optional mit Musterloesung fuer automatische Bewertung. - """ - correction = corrections_store.get(correction_id) - if not correction: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - if correction.status not in [CorrectionStatus.OCR_COMPLETE, CorrectionStatus.ANALYZED]: - raise HTTPException( - status_code=400, - detail=f"Korrektur im falschen Status: {correction.status}" - ) - - if not correction.extracted_text: - raise HTTPException(status_code=400, detail="Kein extrahierter Text vorhanden") - - try: - correction.status = CorrectionStatus.ANALYZING - corrections_store[correction_id] = correction - - # Einfache Analyse ohne LLM - # Teile Text in Abschnitte (simuliert Aufgabenerkennung) - text_parts = correction.extracted_text.split('\n\n') - evaluations = [] - - for i, part in enumerate(text_parts[:10], start=1): # Max 10 Aufgaben - if len(part.strip()) < 5: - continue - - # Simulierte Bewertung - # In Produktion wuerde hier LLM-basierte Analyse stattfinden - expected = expected_answers.get(str(i), "") if expected_answers else "" - - # Einfacher Textvergleich (in Produktion: semantischer Vergleich) - is_correct = bool(expected and expected.lower() in part.lower()) - points = correction.max_points / len(text_parts) if text_parts else 0 - - evaluation = AnswerEvaluation( - question_number=i, - extracted_text=part[:200], # Kuerzen fuer Response - points_possible=points, - points_awarded=points if is_correct else points * 0.5, # Teilpunkte - feedback=f"Antwort zu Aufgabe {i}" + (" korrekt." if is_correct else " mit Verbesserungsbedarf."), - is_correct=is_correct, - confidence=0.7 # Simulierte Confidence - ) - evaluations.append(evaluation) - - # Berechne Gesamtergebnis - total_points = sum(e.points_awarded for e in evaluations) - percentage = (total_points / correction.max_points * 100) if correction.max_points > 0 else 0 - suggested_grade = calculate_grade(percentage) - - # Generiere Feedback - ai_feedback = generate_ai_feedback( - evaluations, total_points, correction.max_points, correction.subject - ) - - # Aktualisiere Korrektur - correction.evaluations = evaluations - correction.total_points = total_points - correction.percentage = percentage - correction.grade = suggested_grade - correction.ai_feedback = ai_feedback - correction.status = CorrectionStatus.ANALYZED - correction.updated_at = datetime.utcnow() - corrections_store[correction_id] = correction - - logger.info(f"Analysis complete for {correction_id}: {total_points}/{correction.max_points}") - - return AnalysisResponse( - success=True, - evaluations=evaluations, - total_points=total_points, - percentage=percentage, - suggested_grade=suggested_grade, - ai_feedback=ai_feedback - ) - - except Exception as e: - logger.error(f"Analysis error: {e}") - correction.status = CorrectionStatus.ERROR - corrections_store[correction_id] = correction - return AnalysisResponse(success=False, error=str(e)) - - -@router.put("/{correction_id}", response_model=CorrectionResponse) -async def update_correction(correction_id: str, data: CorrectionUpdate): - """ - Aktualisiert eine Korrektur. - - Ermoeglicht manuelle Anpassungen durch die Lehrkraft. - """ - correction = corrections_store.get(correction_id) - if not correction: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - if data.evaluations is not None: - correction.evaluations = data.evaluations - correction.total_points = sum(e.points_awarded for e in data.evaluations) - correction.percentage = ( - correction.total_points / correction.max_points * 100 - ) if correction.max_points > 0 else 0 - - if data.total_points is not None: - correction.total_points = data.total_points - correction.percentage = ( - data.total_points / correction.max_points * 100 - ) if correction.max_points > 0 else 0 - - if data.grade is not None: - correction.grade = data.grade - - if data.teacher_notes is not None: - correction.teacher_notes = data.teacher_notes - - if data.status is not None: - correction.status = data.status - - correction.updated_at = datetime.utcnow() - corrections_store[correction_id] = correction - - return CorrectionResponse(success=True, correction=correction) - - -@router.post("/{correction_id}/complete", response_model=CorrectionResponse) -async def complete_correction(correction_id: str): - """Markiert Korrektur als abgeschlossen.""" - correction = corrections_store.get(correction_id) - if not correction: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - correction.status = CorrectionStatus.COMPLETED - correction.updated_at = datetime.utcnow() - corrections_store[correction_id] = correction - - logger.info(f"Correction {correction_id} completed: {correction.grade}") - - return CorrectionResponse(success=True, correction=correction) - - -@router.get("/{correction_id}/export-pdf") -async def export_correction_pdf(correction_id: str): - """ - Exportiert korrigierte Arbeit als PDF. - - Enthaelt: - - Originalscan - - Bewertungen - - Feedback - - Gesamtergebnis - """ - correction = corrections_store.get(correction_id) - if not correction: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - try: - pdf_service = PDFService() - - # Erstelle CorrectionData - correction_data = CorrectionData( - student=StudentInfo( - student_id=correction.student_id, - name=correction.student_name, - class_name=correction.class_name - ), - exam_title=correction.exam_title, - subject=correction.subject, - date=correction.created_at.strftime("%d.%m.%Y"), - max_points=correction.max_points, - achieved_points=correction.total_points, - grade=correction.grade or "", - percentage=correction.percentage, - corrections=[ - { - "question": f"Aufgabe {e.question_number}", - "answer": e.extracted_text, - "points": f"{e.points_awarded}/{e.points_possible}", - "feedback": e.feedback - } - for e in correction.evaluations - ], - teacher_notes=correction.teacher_notes or "", - ai_feedback=correction.ai_feedback or "" - ) - - # Generiere PDF - pdf_bytes = pdf_service.generate_correction_pdf(correction_data) - - from fastapi.responses import Response - - return Response( - content=pdf_bytes, - media_type="application/pdf", - headers={ - "Content-Disposition": f'attachment; filename="korrektur_{correction.student_name}_{correction.exam_title}.pdf"' - } - ) - - except Exception as e: - logger.error(f"PDF export error: {e}") - raise HTTPException(status_code=500, detail=f"PDF-Export fehlgeschlagen: {str(e)}") - - -@router.delete("/{correction_id}") -async def delete_correction(correction_id: str): - """Loescht eine Korrektur.""" - if correction_id not in corrections_store: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - correction = corrections_store[correction_id] - - # Loesche auch die hochgeladene Datei - if correction.file_path and os.path.exists(correction.file_path): - try: - os.remove(correction.file_path) - except Exception as e: - logger.warning(f"Could not delete file {correction.file_path}: {e}") - - del corrections_store[correction_id] - logger.info(f"Deleted correction {correction_id}") - - return {"status": "deleted", "id": correction_id} - - -@router.get("/class/{class_name}/summary") -async def get_class_summary(class_name: str): - """ - Gibt Zusammenfassung fuer eine Klasse zurueck. - - Enthaelt Statistiken ueber alle Korrekturen der Klasse. - """ - class_corrections = [ - c for c in corrections_store.values() - if c.class_name == class_name and c.status == CorrectionStatus.COMPLETED - ] - - if not class_corrections: - return { - "class_name": class_name, - "total_students": 0, - "average_percentage": 0, - "grade_distribution": {}, - "corrections": [] - } - - # Berechne Statistiken - percentages = [c.percentage for c in class_corrections] - average_percentage = sum(percentages) / len(percentages) if percentages else 0 - - # Notenverteilung - grade_distribution = {} - for c in class_corrections: - grade = c.grade or "?" - grade_distribution[grade] = grade_distribution.get(grade, 0) + 1 - - return { - "class_name": class_name, - "total_students": len(class_corrections), - "average_percentage": round(average_percentage, 1), - "average_points": round( - sum(c.total_points for c in class_corrections) / len(class_corrections), 1 - ), - "grade_distribution": grade_distribution, - "corrections": [ - { - "id": c.id, - "student_name": c.student_name, - "total_points": c.total_points, - "percentage": c.percentage, - "grade": c.grade - } - for c in sorted(class_corrections, key=lambda x: x.student_name) - ] - } - - -@router.post("/{correction_id}/ocr/retry", response_model=CorrectionResponse) -async def retry_ocr(correction_id: str, background_tasks: BackgroundTasks): - """ - Wiederholt OCR-Verarbeitung. - - Nuetzlich wenn erste Verarbeitung fehlgeschlagen ist. - """ - correction = corrections_store.get(correction_id) - if not correction: - raise HTTPException(status_code=404, detail="Korrektur nicht gefunden") - - if not correction.file_path: - raise HTTPException(status_code=400, detail="Keine Datei vorhanden") - - if not os.path.exists(correction.file_path): - raise HTTPException(status_code=400, detail="Datei nicht mehr vorhanden") - - # Starte OCR erneut - correction.status = CorrectionStatus.UPLOADED - correction.extracted_text = None - correction.updated_at = datetime.utcnow() - corrections_store[correction_id] = correction - - background_tasks.add_task(process_ocr, correction_id, correction.file_path) - - return CorrectionResponse(success=True, correction=correction) +# Backward-compat shim -- module moved to correction/endpoints.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("correction.endpoints") diff --git a/backend-lehrer/correction_helpers.py b/backend-lehrer/correction_helpers.py index d276830..ef007ef 100644 --- a/backend-lehrer/correction_helpers.py +++ b/backend-lehrer/correction_helpers.py @@ -1,134 +1,4 @@ -""" -Correction API - Helper functions for grading, feedback, and OCR processing. -""" - -import logging -from typing import List, Dict - -from correction_models import AnswerEvaluation, CorrectionStatus, Correction - -logger = logging.getLogger(__name__) - -# FileProcessor requires OpenCV with libGL - make optional for CI -try: - from services.file_processor import FileProcessor, ProcessingResult - _ocr_available = True -except (ImportError, OSError): - FileProcessor = None # type: ignore - ProcessingResult = None # type: ignore - _ocr_available = False - -# PDF service requires WeasyPrint with system libraries - make optional for CI -try: - from services.pdf_service import PDFService, CorrectionData, StudentInfo - _pdf_available = True -except (ImportError, OSError): - PDFService = None # type: ignore - CorrectionData = None # type: ignore - StudentInfo = None # type: ignore - _pdf_available = False - - -# ============================================================================ -# In-Memory Storage (spaeter durch DB ersetzen) -# ============================================================================ - -corrections_store: Dict[str, Correction] = {} - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def calculate_grade(percentage: float) -> str: - """Berechnet Note aus Prozent (deutsches System).""" - if percentage >= 92: - return "1" - elif percentage >= 81: - return "2" - elif percentage >= 67: - return "3" - elif percentage >= 50: - return "4" - elif percentage >= 30: - return "5" - else: - return "6" - - -def generate_ai_feedback( - evaluations: List[AnswerEvaluation], - total_points: float, - max_points: float, - subject: str -) -> str: - """Generiert KI-Feedback basierend auf Bewertung.""" - # Ohne LLM: Einfaches Template-basiertes Feedback - percentage = (total_points / max_points * 100) if max_points > 0 else 0 - correct_count = sum(1 for e in evaluations if e.is_correct) - total_count = len(evaluations) - - if percentage >= 90: - intro = "Hervorragende Leistung!" - elif percentage >= 75: - intro = "Gute Arbeit!" - elif percentage >= 60: - intro = "Insgesamt eine solide Leistung." - elif percentage >= 50: - intro = "Die Arbeit zeigt Grundkenntnisse, aber es gibt Verbesserungsbedarf." - else: - intro = "Es sind deutliche Wissensluecken erkennbar." - - # Finde Verbesserungsbereiche - weak_areas = [e for e in evaluations if not e.is_correct] - strengths = [e for e in evaluations if e.is_correct and e.confidence > 0.8] - - feedback_parts = [intro] - - if strengths: - feedback_parts.append( - f"Besonders gut geloest: Aufgabe(n) {', '.join(str(s.question_number) for s in strengths[:3])}." - ) - - if weak_areas: - feedback_parts.append( - f"Uebungsbedarf bei: Aufgabe(n) {', '.join(str(w.question_number) for w in weak_areas[:3])}." - ) - - feedback_parts.append( - f"Ergebnis: {correct_count} von {total_count} Aufgaben korrekt ({percentage:.1f}%)." - ) - - return " ".join(feedback_parts) - - -async def process_ocr(correction_id: str, file_path: str): - """Background Task fuer OCR-Verarbeitung.""" - from datetime import datetime - - correction = corrections_store.get(correction_id) - if not correction: - return - - try: - correction.status = CorrectionStatus.PROCESSING - corrections_store[correction_id] = correction - - # OCR durchfuehren - processor = FileProcessor() - result = processor.process_file(file_path) - - if result.success and result.text: - correction.extracted_text = result.text - correction.status = CorrectionStatus.OCR_COMPLETE - else: - correction.status = CorrectionStatus.ERROR - - correction.updated_at = datetime.utcnow() - corrections_store[correction_id] = correction - - except Exception as e: - logger.error(f"OCR error for {correction_id}: {e}") - correction.status = CorrectionStatus.ERROR - correction.updated_at = datetime.utcnow() - corrections_store[correction_id] = correction +# Backward-compat shim -- module moved to correction/helpers.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("correction.helpers") diff --git a/backend-lehrer/correction_models.py b/backend-lehrer/correction_models.py index 010ef17..56b2caa 100644 --- a/backend-lehrer/correction_models.py +++ b/backend-lehrer/correction_models.py @@ -1,111 +1,4 @@ -""" -Correction API - Pydantic models and enums. -""" - -from datetime import datetime -from typing import List, Dict, Any, Optional -from enum import Enum -from pathlib import Path - -from pydantic import BaseModel, Field - - -# Upload directory -UPLOAD_DIR = Path("/tmp/corrections") -UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - - -# ============================================================================ -# Enums and Models -# ============================================================================ - -class CorrectionStatus(str, Enum): - """Status einer Korrektur.""" - UPLOADED = "uploaded" # Datei hochgeladen - PROCESSING = "processing" # OCR laeuft - OCR_COMPLETE = "ocr_complete" # OCR abgeschlossen - ANALYZING = "analyzing" # Analyse laeuft - ANALYZED = "analyzed" # Analyse abgeschlossen - REVIEWING = "reviewing" # Lehrkraft prueft - COMPLETED = "completed" # Korrektur abgeschlossen - ERROR = "error" # Fehler aufgetreten - - -class AnswerEvaluation(BaseModel): - """Bewertung einer einzelnen Antwort.""" - question_number: int - extracted_text: str - points_possible: float - points_awarded: float - feedback: str - is_correct: bool - confidence: float # 0-1, wie sicher die OCR/Analyse ist - - -class CorrectionCreate(BaseModel): - """Request zum Erstellen einer neuen Korrektur.""" - student_id: str - student_name: str - class_name: str - exam_title: str - subject: str - max_points: float = Field(default=100.0, ge=0) - expected_answers: Optional[Dict[str, str]] = None # Musterloesung - - -class CorrectionUpdate(BaseModel): - """Request zum Aktualisieren einer Korrektur.""" - evaluations: Optional[List[AnswerEvaluation]] = None - total_points: Optional[float] = None - grade: Optional[str] = None - teacher_notes: Optional[str] = None - status: Optional[CorrectionStatus] = None - - -class Correction(BaseModel): - """Eine Korrektur.""" - id: str - student_id: str - student_name: str - class_name: str - exam_title: str - subject: str - max_points: float - total_points: float = 0.0 - percentage: float = 0.0 - grade: Optional[str] = None - status: CorrectionStatus - file_path: Optional[str] = None - extracted_text: Optional[str] = None - evaluations: List[AnswerEvaluation] = [] - teacher_notes: Optional[str] = None - ai_feedback: Optional[str] = None - created_at: datetime - updated_at: datetime - - -class CorrectionResponse(BaseModel): - """Response fuer eine Korrektur.""" - success: bool - correction: Optional[Correction] = None - error: Optional[str] = None - - -class OCRResponse(BaseModel): - """Response fuer OCR-Ergebnis.""" - success: bool - extracted_text: Optional[str] = None - regions: List[Dict[str, Any]] = [] - confidence: float = 0.0 - error: Optional[str] = None - - -class AnalysisResponse(BaseModel): - """Response fuer Analyse-Ergebnis.""" - success: bool - evaluations: List[AnswerEvaluation] = [] - total_points: float = 0.0 - percentage: float = 0.0 - suggested_grade: Optional[str] = None - ai_feedback: Optional[str] = None - error: Optional[str] = None +# Backward-compat shim -- module moved to correction/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("correction.models") diff --git a/backend-lehrer/dashboard/__init__.py b/backend-lehrer/dashboard/__init__.py new file mode 100644 index 0000000..84da914 --- /dev/null +++ b/backend-lehrer/dashboard/__init__.py @@ -0,0 +1 @@ +# dashboard — Teacher dashboard, unit assignments, analytics. diff --git a/backend-lehrer/dashboard/analytics.py b/backend-lehrer/dashboard/analytics.py new file mode 100644 index 0000000..c826ace --- /dev/null +++ b/backend-lehrer/dashboard/analytics.py @@ -0,0 +1,267 @@ +# ============================================== +# Teacher Dashboard - Analytics & Progress Routes +# ============================================== + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +import logging + +from .models import ( + UnitAssignmentStatus, TeacherControlSettings, + UnitAssignment, StudentUnitProgress, ClassUnitProgress, + MisconceptionReport, ClassAnalyticsSummary, ContentResource, + get_current_teacher, get_teacher_database, + get_classes_for_teacher, get_students_in_class, + REQUIRE_AUTH, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Teacher Dashboard"]) + +# Shared in-memory store reference (set from teacher_dashboard_api) +_assignments_store: Dict[str, Dict[str, Any]] = {} + + +def set_assignments_store(store: Dict[str, Dict[str, Any]]): + """Share the in-memory assignments store from the main module.""" + global _assignments_store + _assignments_store = store + + +# ============================================== +# API Endpoints - Progress & Analytics +# ============================================== + +@router.get("/assignments/{assignment_id}/progress", response_model=ClassUnitProgress) +async def get_assignment_progress( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> ClassUnitProgress: + """Get detailed progress for an assignment.""" + db = await get_teacher_database() + assignment = None + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + students = await get_students_in_class(assignment["class_id"]) + student_progress = [] + total_completion = 0.0 + total_precheck = 0.0 + total_postcheck = 0.0 + total_time = 0 + precheck_count = 0 + postcheck_count = 0 + started = 0 + completed = 0 + + for student in students: + student_id = student.get("id", student.get("student_id")) + progress = StudentUnitProgress( + student_id=student_id, + student_name=student.get("name", f"Student {student_id[:8]}"), + status="not_started", completion_rate=0.0, stops_completed=0, total_stops=0, + ) + if db: + try: + session_data = await db.get_student_unit_session( + student_id=student_id, unit_id=assignment["unit_id"] + ) + if session_data: + progress.session_id = session_data.get("session_id") + progress.status = "completed" if session_data.get("completed_at") else "in_progress" + progress.completion_rate = session_data.get("completion_rate", 0.0) + progress.precheck_score = session_data.get("precheck_score") + progress.postcheck_score = session_data.get("postcheck_score") + progress.time_spent_minutes = session_data.get("duration_seconds", 0) // 60 + progress.last_activity = session_data.get("updated_at") + progress.stops_completed = session_data.get("stops_completed", 0) + progress.total_stops = session_data.get("total_stops", 0) + if progress.precheck_score is not None and progress.postcheck_score is not None: + progress.learning_gain = progress.postcheck_score - progress.precheck_score + total_completion += progress.completion_rate + total_time += progress.time_spent_minutes + if progress.precheck_score is not None: + total_precheck += progress.precheck_score + precheck_count += 1 + if progress.postcheck_score is not None: + total_postcheck += progress.postcheck_score + postcheck_count += 1 + if progress.status != "not_started": + started += 1 + if progress.status == "completed": + completed += 1 + except Exception as e: + logger.error(f"Failed to get student progress: {e}") + student_progress.append(progress) + + total_students = len(students) or 1 + return ClassUnitProgress( + assignment_id=assignment_id, unit_id=assignment["unit_id"], + unit_title=f"Unit {assignment['unit_id']}", class_id=assignment["class_id"], + class_name=f"Class {assignment['class_id'][:8]}", total_students=len(students), + started_count=started, completed_count=completed, + avg_completion_rate=total_completion / total_students, + avg_precheck_score=total_precheck / precheck_count if precheck_count > 0 else None, + avg_postcheck_score=total_postcheck / postcheck_count if postcheck_count > 0 else None, + avg_learning_gain=(total_postcheck / postcheck_count - total_precheck / precheck_count) + if precheck_count > 0 and postcheck_count > 0 else None, + avg_time_minutes=total_time / started if started > 0 else 0, + students=student_progress, + ) + + +@router.get("/classes/{class_id}/analytics", response_model=ClassAnalyticsSummary) +async def get_class_analytics( + class_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> ClassAnalyticsSummary: + """Get summary analytics for a class.""" + db = await get_teacher_database() + assignments = [] + if db: + try: + assignments = await db.list_assignments(teacher_id=teacher["user_id"], class_id=class_id) + except Exception as e: + logger.error(f"Failed to list assignments: {e}") + if not assignments: + assignments = [ + a for a in _assignments_store.values() + if a["class_id"] == class_id and a["teacher_id"] == teacher["user_id"] + ] + + total_units = len(assignments) + completed_units = sum(1 for a in assignments if a.get("status") == "completed") + active_units = sum(1 for a in assignments if a.get("status") == "active") + + students = await get_students_in_class(class_id) + student_scores = {} + misconceptions = [] + if db: + try: + for student in students: + student_id = student.get("id", student.get("student_id")) + analytics = await db.get_student_analytics(student_id) + if analytics: + student_scores[student_id] = { + "name": student.get("name", student_id[:8]), + "avg_score": analytics.get("avg_postcheck_score", 0), + "total_time": analytics.get("total_time_minutes", 0), + } + misconceptions_data = await db.get_class_misconceptions(class_id) + for m in misconceptions_data: + misconceptions.append(MisconceptionReport( + concept_id=m["concept_id"], concept_label=m["concept_label"], + misconception=m["misconception"], affected_students=m["affected_students"], + frequency=m["frequency"], unit_id=m["unit_id"], stop_id=m["stop_id"], + )) + except Exception as e: + logger.error(f"Failed to aggregate analytics: {e}") + + sorted_students = sorted(student_scores.items(), key=lambda x: x[1]["avg_score"], reverse=True) + top_performers = [s[1]["name"] for s in sorted_students[:3]] + struggling_students = [s[1]["name"] for s in sorted_students[-3:] if s[1]["avg_score"] < 0.6] + total_time = sum(s["total_time"] for s in student_scores.values()) + avg_scores = [s["avg_score"] for s in student_scores.values() if s["avg_score"] > 0] + avg_completion = sum(avg_scores) / len(avg_scores) if avg_scores else 0 + + return ClassAnalyticsSummary( + class_id=class_id, class_name=f"Klasse {class_id[:8]}", + total_units_assigned=total_units, units_completed=completed_units, + active_units=active_units, avg_completion_rate=avg_completion, + avg_learning_gain=None, total_time_hours=total_time / 60, + top_performers=top_performers, struggling_students=struggling_students, + common_misconceptions=misconceptions[:5], + ) + + +@router.get("/students/{student_id}/progress") +async def get_student_progress( + student_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, Any]: + """Get detailed progress for a specific student.""" + db = await get_teacher_database() + if db: + try: + progress = await db.get_student_full_progress(student_id) + return progress + except Exception as e: + logger.error(f"Failed to get student progress: {e}") + return { + "student_id": student_id, "units_attempted": 0, "units_completed": 0, + "avg_score": 0.0, "total_time_minutes": 0, "sessions": [], + } + + +# ============================================== +# API Endpoints - Content Resources +# ============================================== + +@router.get("/assignments/{assignment_id}/resources", response_model=List[ContentResource]) +async def get_assignment_resources( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher), + request: Request = None +) -> List[ContentResource]: + """Get generated content resources for an assignment.""" + db = await get_teacher_database() + assignment = None + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + unit_id = assignment["unit_id"] + base_url = str(request.base_url).rstrip("/") if request else "http://localhost:8000" + return [ + ContentResource(resource_type="h5p", title=f"{unit_id} - H5P Aktivitaeten", + url=f"{base_url}/api/units/content/{unit_id}/h5p", + generated_at=datetime.utcnow(), unit_id=unit_id), + ContentResource(resource_type="worksheet", title=f"{unit_id} - Arbeitsblatt (HTML)", + url=f"{base_url}/api/units/content/{unit_id}/worksheet", + generated_at=datetime.utcnow(), unit_id=unit_id), + ContentResource(resource_type="pdf", title=f"{unit_id} - Arbeitsblatt (PDF)", + url=f"{base_url}/api/units/content/{unit_id}/worksheet.pdf", + generated_at=datetime.utcnow(), unit_id=unit_id), + ] + + +@router.post("/assignments/{assignment_id}/regenerate-content") +async def regenerate_content( + assignment_id: str, + resource_type: str = Query("all", description="h5p, pdf, or all"), + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, Any]: + """Trigger regeneration of content resources.""" + db = await get_teacher_database() + assignment = None + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + logger.info(f"Content regeneration triggered for {assignment['unit_id']}: {resource_type}") + return { + "status": "queued", "assignment_id": assignment_id, + "unit_id": assignment["unit_id"], "resource_type": resource_type, + "message": "Content regeneration has been queued", + } diff --git a/backend-lehrer/dashboard/api.py b/backend-lehrer/dashboard/api.py new file mode 100644 index 0000000..dbcfbbd --- /dev/null +++ b/backend-lehrer/dashboard/api.py @@ -0,0 +1,329 @@ +# ============================================== +# Breakpilot Drive - Teacher Dashboard API +# ============================================== +# Lehrer-Dashboard fuer Unit-Zuweisung und Analytics. +# +# Split structure: +# - teacher_dashboard_models.py: Models, Auth, DB/School helpers +# - teacher_dashboard_analytics.py: Progress, analytics, content routes +# - teacher_dashboard_api.py: Assignment CRUD, dashboard, units (this file) + +from fastapi import APIRouter, HTTPException, Query, Depends +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +import uuid +import logging + +from .models import ( + UnitAssignmentStatus, TeacherControlSettings, AssignUnitRequest, + UnitAssignment, + get_current_teacher, get_teacher_database, + get_classes_for_teacher, + REQUIRE_AUTH, +) +from .analytics import ( + router as analytics_router, + set_assignments_store, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/teacher", tags=["Teacher Dashboard"]) + +# In-Memory Storage (Fallback) +_assignments_store: Dict[str, Dict[str, Any]] = {} + +# Share the store with the analytics module and include its routes +set_assignments_store(_assignments_store) +router.include_router(analytics_router) + + +# ============================================== +# API Endpoints - Unit Assignment +# ============================================== + +@router.post("/assignments", response_model=UnitAssignment) +async def assign_unit_to_class( + request_data: AssignUnitRequest, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> UnitAssignment: + """Assign a unit to a class.""" + assignment_id = str(uuid.uuid4()) + now = datetime.utcnow() + settings = request_data.settings or TeacherControlSettings() + + assignment = { + "assignment_id": assignment_id, "unit_id": request_data.unit_id, + "class_id": request_data.class_id, "teacher_id": teacher["user_id"], + "status": UnitAssignmentStatus.ACTIVE, "settings": settings.model_dump(), + "due_date": request_data.due_date, "notes": request_data.notes, + "created_at": now, "updated_at": now, + } + + db = await get_teacher_database() + if db: + try: + await db.create_assignment(assignment) + except Exception as e: + logger.error(f"Failed to store assignment: {e}") + + _assignments_store[assignment_id] = assignment + logger.info(f"Unit {request_data.unit_id} assigned to class {request_data.class_id}") + + return UnitAssignment( + assignment_id=assignment_id, unit_id=request_data.unit_id, + class_id=request_data.class_id, teacher_id=teacher["user_id"], + status=UnitAssignmentStatus.ACTIVE, settings=settings, + due_date=request_data.due_date, notes=request_data.notes, + created_at=now, updated_at=now, + ) + + +@router.get("/assignments", response_model=List[UnitAssignment]) +async def list_assignments( + class_id: Optional[str] = Query(None, description="Filter by class"), + status: Optional[UnitAssignmentStatus] = Query(None, description="Filter by status"), + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> List[UnitAssignment]: + """List all unit assignments for the teacher.""" + db = await get_teacher_database() + assignments = [] + + if db: + try: + assignments = await db.list_assignments( + teacher_id=teacher["user_id"], + class_id=class_id, + status=status.value if status else None + ) + except Exception as e: + logger.error(f"Failed to list assignments: {e}") + + if not assignments: + for assignment in _assignments_store.values(): + if assignment["teacher_id"] != teacher["user_id"]: + continue + if class_id and assignment["class_id"] != class_id: + continue + if status and assignment["status"] != status.value: + continue + assignments.append(assignment) + + return [ + UnitAssignment( + assignment_id=a["assignment_id"], unit_id=a["unit_id"], + class_id=a["class_id"], teacher_id=a["teacher_id"], + status=a["status"], settings=TeacherControlSettings(**a["settings"]), + due_date=a.get("due_date"), notes=a.get("notes"), + created_at=a["created_at"], updated_at=a["updated_at"], + ) + for a in assignments + ] + + +@router.get("/assignments/{assignment_id}", response_model=UnitAssignment) +async def get_assignment( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> UnitAssignment: + """Get details of a specific assignment.""" + db = await get_teacher_database() + if db: + try: + assignment = await db.get_assignment(assignment_id) + if assignment and assignment["teacher_id"] == teacher["user_id"]: + return UnitAssignment( + assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"], + class_id=assignment["class_id"], teacher_id=assignment["teacher_id"], + status=assignment["status"], + settings=TeacherControlSettings(**assignment["settings"]), + due_date=assignment.get("due_date"), notes=assignment.get("notes"), + created_at=assignment["created_at"], updated_at=assignment["updated_at"], + ) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + + if assignment_id in _assignments_store: + a = _assignments_store[assignment_id] + if a["teacher_id"] == teacher["user_id"]: + return UnitAssignment( + assignment_id=a["assignment_id"], unit_id=a["unit_id"], + class_id=a["class_id"], teacher_id=a["teacher_id"], + status=a["status"], settings=TeacherControlSettings(**a["settings"]), + due_date=a.get("due_date"), notes=a.get("notes"), + created_at=a["created_at"], updated_at=a["updated_at"], + ) + + raise HTTPException(status_code=404, detail="Assignment not found") + + +@router.put("/assignments/{assignment_id}") +async def update_assignment( + assignment_id: str, + settings: Optional[TeacherControlSettings] = None, + status: Optional[UnitAssignmentStatus] = None, + due_date: Optional[datetime] = None, + notes: Optional[str] = None, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> UnitAssignment: + """Update assignment settings or status.""" + db = await get_teacher_database() + assignment = None + + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + if settings: + assignment["settings"] = settings.model_dump() + if status: + assignment["status"] = status.value + if due_date: + assignment["due_date"] = due_date + if notes is not None: + assignment["notes"] = notes + assignment["updated_at"] = datetime.utcnow() + + if db: + try: + await db.update_assignment(assignment) + except Exception as e: + logger.error(f"Failed to update assignment: {e}") + + _assignments_store[assignment_id] = assignment + + return UnitAssignment( + assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"], + class_id=assignment["class_id"], teacher_id=assignment["teacher_id"], + status=assignment["status"], settings=TeacherControlSettings(**assignment["settings"]), + due_date=assignment.get("due_date"), notes=assignment.get("notes"), + created_at=assignment["created_at"], updated_at=assignment["updated_at"], + ) + + +@router.delete("/assignments/{assignment_id}") +async def delete_assignment( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, str]: + """Delete/archive an assignment.""" + db = await get_teacher_database() + if db: + try: + assignment = await db.get_assignment(assignment_id) + if assignment and assignment["teacher_id"] == teacher["user_id"]: + await db.delete_assignment(assignment_id) + if assignment_id in _assignments_store: + del _assignments_store[assignment_id] + return {"status": "deleted", "assignment_id": assignment_id} + except Exception as e: + logger.error(f"Failed to delete assignment: {e}") + + if assignment_id in _assignments_store: + a = _assignments_store[assignment_id] + if a["teacher_id"] == teacher["user_id"]: + del _assignments_store[assignment_id] + return {"status": "deleted", "assignment_id": assignment_id} + + raise HTTPException(status_code=404, detail="Assignment not found") + + +# ============================================== +# API Endpoints - Available Units +# ============================================== + +@router.get("/units/available") +async def list_available_units( + grade: Optional[str] = Query(None, description="Filter by grade level"), + template: Optional[str] = Query(None, description="Filter by template type"), + locale: str = Query("de-DE", description="Locale"), + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> List[Dict[str, Any]]: + """List all available units for assignment.""" + db = await get_teacher_database() + if db: + try: + units = await db.list_available_units(grade=grade, template=template, locale=locale) + return units + except Exception as e: + logger.error(f"Failed to list units: {e}") + return [ + { + "unit_id": "bio_eye_lightpath_v1", "title": "Auge - Lichtstrahl-Flug", + "template": "flight_path", "grade_band": ["5", "6", "7"], + "duration_minutes": 8, "difficulty": "base", + "description": "Reise durch das Auge und folge dem Lichtstrahl", + "learning_objectives": ["Verstehen des Lichtwegs durch das Auge", + "Funktionen der Augenbestandteile benennen"], + }, + { + "unit_id": "math_pizza_equivalence_v1", + "title": "Pizza-Boxenstopp - Brueche und Prozent", + "template": "station_loop", "grade_band": ["5", "6"], + "duration_minutes": 10, "difficulty": "base", + "description": "Entdecke die Verbindung zwischen Bruechen, Dezimalzahlen und Prozent", + "learning_objectives": ["Brueche in Prozent umrechnen", "Aequivalenzen erkennen"], + }, + ] + + +# ============================================== +# API Endpoints - Dashboard Overview +# ============================================== + +@router.get("/dashboard") +async def get_dashboard( + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, Any]: + """Get teacher dashboard overview.""" + db = await get_teacher_database() + classes = await get_classes_for_teacher(teacher["user_id"]) + + active_assignments = [] + if db: + try: + active_assignments = await db.list_assignments( + teacher_id=teacher["user_id"], status="active" + ) + except Exception as e: + logger.error(f"Failed to list assignments: {e}") + if not active_assignments: + active_assignments = [ + a for a in _assignments_store.values() + if a["teacher_id"] == teacher["user_id"] and a.get("status") == "active" + ] + + alerts = [] + for assignment in active_assignments: + if assignment.get("due_date") and assignment["due_date"] < datetime.utcnow() + timedelta(days=2): + alerts.append({ + "type": "due_soon", "assignment_id": assignment["assignment_id"], + "message": "Zuweisung endet in weniger als 2 Tagen", + }) + + return { + "teacher": {"id": teacher["user_id"], "name": teacher.get("name", "Lehrer"), + "email": teacher.get("email")}, + "classes": len(classes), "active_assignments": len(active_assignments), + "total_students": sum(c.get("student_count", 0) for c in classes), + "alerts": alerts, "recent_activity": [], + } + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """Health check for teacher dashboard API.""" + db = await get_teacher_database() + db_status = "connected" if db else "in-memory" + return { + "status": "healthy", "service": "teacher-dashboard", + "database": db_status, "auth_required": REQUIRE_AUTH, + } diff --git a/backend-lehrer/dashboard/models.py b/backend-lehrer/dashboard/models.py new file mode 100644 index 0000000..88e6d9c --- /dev/null +++ b/backend-lehrer/dashboard/models.py @@ -0,0 +1,226 @@ +""" +Teacher Dashboard - Pydantic Models, Auth Dependency, and Service Helpers. +""" + +import os +import logging +from datetime import datetime +from typing import List, Optional, Dict, Any +from enum import Enum + +from fastapi import HTTPException, Request +from pydantic import BaseModel +import httpx + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("TEACHER_REQUIRE_AUTH", "true").lower() == "true" +SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") + + +# ============================================== +# Pydantic Models +# ============================================== + +class UnitAssignmentStatus(str, Enum): + """Status of a unit assignment""" + DRAFT = "draft" + ACTIVE = "active" + COMPLETED = "completed" + ARCHIVED = "archived" + + +class TeacherControlSettings(BaseModel): + """Unit settings that teachers can configure""" + allow_skip: bool = True + allow_replay: bool = True + max_time_per_stop_sec: int = 90 + show_hints: bool = True + require_precheck: bool = True + require_postcheck: bool = True + + +class AssignUnitRequest(BaseModel): + """Request to assign a unit to a class""" + unit_id: str + class_id: str + due_date: Optional[datetime] = None + settings: Optional[TeacherControlSettings] = None + notes: Optional[str] = None + + +class UnitAssignment(BaseModel): + """Unit assignment record""" + assignment_id: str + unit_id: str + class_id: str + teacher_id: str + status: UnitAssignmentStatus + settings: TeacherControlSettings + due_date: Optional[datetime] = None + notes: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class StudentUnitProgress(BaseModel): + """Progress of a single student on a unit""" + student_id: str + student_name: str + session_id: Optional[str] = None + status: str # "not_started", "in_progress", "completed" + completion_rate: float = 0.0 + precheck_score: Optional[float] = None + postcheck_score: Optional[float] = None + learning_gain: Optional[float] = None + time_spent_minutes: int = 0 + last_activity: Optional[datetime] = None + current_stop: Optional[str] = None + stops_completed: int = 0 + total_stops: int = 0 + + +class ClassUnitProgress(BaseModel): + """Overall progress of a class on a unit""" + assignment_id: str + unit_id: str + unit_title: str + class_id: str + class_name: str + total_students: int + started_count: int + completed_count: int + avg_completion_rate: float + avg_precheck_score: Optional[float] = None + avg_postcheck_score: Optional[float] = None + avg_learning_gain: Optional[float] = None + avg_time_minutes: float + students: List[StudentUnitProgress] + + +class MisconceptionReport(BaseModel): + """Report of detected misconceptions""" + concept_id: str + concept_label: str + misconception: str + affected_students: List[str] + frequency: int + unit_id: str + stop_id: str + + +class ClassAnalyticsSummary(BaseModel): + """Summary analytics for a class""" + class_id: str + class_name: str + total_units_assigned: int + units_completed: int + active_units: int + avg_completion_rate: float + avg_learning_gain: Optional[float] + total_time_hours: float + top_performers: List[str] + struggling_students: List[str] + common_misconceptions: List[MisconceptionReport] + + +class ContentResource(BaseModel): + """Generated content resource""" + resource_type: str # "h5p", "pdf", "worksheet" + title: str + url: str + generated_at: datetime + unit_id: str + + +# ============================================== +# Auth Dependency +# ============================================== + +async def get_current_teacher(request: Request) -> Dict[str, Any]: + """Get current teacher from JWT token.""" + if not REQUIRE_AUTH: + return { + "user_id": "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20", + "email": "demo@breakpilot.app", + "role": "teacher", + "name": "Demo Lehrer" + } + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Missing authorization token") + + try: + import jwt + token = auth_header[7:] + secret = os.getenv("JWT_SECRET", "dev-secret-key") + payload = jwt.decode(token, secret, algorithms=["HS256"]) + + if payload.get("role") not in ["teacher", "admin"]: + raise HTTPException(status_code=403, detail="Teacher or admin role required") + + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + + +# ============================================== +# Database Integration +# ============================================== + +_teacher_db = None + + +async def get_teacher_database(): + """Get teacher database instance with lazy initialization.""" + global _teacher_db + if not USE_DATABASE: + return None + if _teacher_db is None: + try: + from unit.database import get_teacher_db + _teacher_db = await get_teacher_db() + logger.info("Teacher database initialized") + except ImportError: + logger.warning("Teacher database module not available") + except Exception as e: + logger.warning(f"Teacher database not available: {e}") + return _teacher_db + + +# ============================================== +# School Service Integration +# ============================================== + +async def get_classes_for_teacher(teacher_id: str) -> List[Dict[str, Any]]: + """Get classes assigned to a teacher from school service.""" + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.get( + f"{SCHOOL_SERVICE_URL}/api/v1/school/classes", + headers={"X-Teacher-ID": teacher_id} + ) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Failed to get classes from school service: {e}") + return [] + + +async def get_students_in_class(class_id: str) -> List[Dict[str, Any]]: + """Get students in a class from school service.""" + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.get( + f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students" + ) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Failed to get students from school service: {e}") + return [] diff --git a/backend-lehrer/game/api.py b/backend-lehrer/game/api.py new file mode 100644 index 0000000..f336a47 --- /dev/null +++ b/backend-lehrer/game/api.py @@ -0,0 +1,46 @@ +# ============================================== +# Breakpilot Drive - Game API (barrel re-export) +# ============================================== +# This module was split into: +# - game_models.py (Pydantic models, difficulty mapping, sample questions) +# - game_routes.py (Core game routes: level, quiz, session, leaderboard) +# - game_extended_routes.py (Phase 5: achievements, progress, parent, class) +# +# The `router` object is assembled here by including all sub-routers. +# Importers that did `from game_api import router` continue to work. + +from fastapi import APIRouter + +from .routes import router as _core_router +from .session_routes import router as _session_router +from .extended_routes import router as _extended_router + +# Re-export models for any direct importers +from .game_models import ( # noqa: F401 + LearningLevel, + GameDifficulty, + QuizQuestion, + QuizAnswer, + GameSession, + SessionResponse, + DIFFICULTY_MAPPING, + SAMPLE_QUESTIONS, +) + +# Re-export helpers/state for any direct importers +from .routes import ( # noqa: F401 + get_optional_current_user, + get_user_id_from_auth, + get_game_database, + _sessions, + _user_levels, + USE_DATABASE, + REQUIRE_AUTH, +) + +# Assemble the combined router. +# Both sub-routers use prefix="/api/game", so include without extra prefix. +router = APIRouter() +router.include_router(_core_router) +router.include_router(_session_router) +router.include_router(_extended_router) diff --git a/backend-lehrer/game/extended_routes.py b/backend-lehrer/game/extended_routes.py new file mode 100644 index 0000000..54bc62b --- /dev/null +++ b/backend-lehrer/game/extended_routes.py @@ -0,0 +1,189 @@ +# ============================================== +# Breakpilot Drive - Game Extended Routes +# ============================================== +# Phase 5 features: achievements, progress, parent dashboard, +# class leaderboard, and display leaderboard. +# Extracted from game_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +import logging + +from .routes import ( + get_optional_current_user, + get_user_id_from_auth, + get_game_database, + REQUIRE_AUTH, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) + + +# ============================================== +# Phase 5: Erweiterte Features +# ============================================== + +@router.get("/achievements/{user_id}") +async def get_achievements( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck. + + Achievements werden basierend auf Spielstatistiken berechnet. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"achievements": [], "message": "Database not available"} + + try: + achievements = await db.get_student_achievements(user_id) + + unlocked = [a for a in achievements if a.unlocked] + locked = [a for a in achievements if not a.unlocked] + + return { + "user_id": user_id, + "total": len(achievements), + "unlocked_count": len(unlocked), + "achievements": [ + { + "id": a.id, + "name": a.name, + "description": a.description, + "icon": a.icon, + "category": a.category, + "threshold": a.threshold, + "progress": a.progress, + "unlocked": a.unlocked, + } + for a in achievements + ] + } + except Exception as e: + logger.error(f"Failed to get achievements: {e}") + return {"achievements": [], "message": str(e)} + + +@router.get("/progress/{user_id}") +async def get_progress( + user_id: str, + days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts). + + - Taegliche Statistiken + - Fuer Eltern-Dashboard und Fortschrittsanzeige + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"progress": [], "message": "Database not available"} + + try: + progress = await db.get_progress_over_time(user_id, days) + return { + "user_id": user_id, + "days": days, + "data_points": len(progress), + "progress": progress, + } + except Exception as e: + logger.error(f"Failed to get progress: {e}") + return {"progress": [], "message": str(e)} + + +@router.get("/parent/children") +async def get_children_dashboard( + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Eltern-Dashboard: Statistiken fuer alle Kinder. + + Erfordert Auth mit Eltern-Rolle und children_ids Claim. + """ + if not REQUIRE_AUTH or user is None: + return { + "message": "Auth required for parent dashboard", + "children": [] + } + + # Get children IDs from token + children_ids = user.get("raw_claims", {}).get("children_ids", []) + + if not children_ids: + return { + "message": "No children associated with this account", + "children": [] + } + + db = await get_game_database() + if not db: + return {"children": [], "message": "Database not available"} + + try: + children_stats = await db.get_children_stats(children_ids) + return { + "parent_id": user.get("user_id"), + "children_count": len(children_ids), + "children": children_stats, + } + except Exception as e: + logger.error(f"Failed to get children stats: {e}") + return {"children": [], "message": str(e)} + + +@router.get("/leaderboard/class/{class_id}") +async def get_class_leaderboard( + class_id: str, + timeframe: str = Query("week", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=50), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[dict]: + """ + Klassenspezifische Rangliste. + + Nur fuer Lehrer oder Schueler der Klasse sichtbar. + """ + db = await get_game_database() + if not db: + return [] + + try: + leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit) + return leaderboard + except Exception as e: + logger.error(f"Failed to get class leaderboard: {e}") + return [] + + +@router.get("/leaderboard/display") +async def get_display_leaderboard( + timeframe: str = Query("day", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=100), + anonymize: bool = Query(True, description="Namen anonymisieren") +) -> List[dict]: + """ + Oeffentliche Rangliste mit Anzeigenamen. + + Standardmaessig anonymisiert fuer Datenschutz. + """ + db = await get_game_database() + if not db: + return [] + + try: + return await db.get_leaderboard_with_names(timeframe, limit, anonymize) + except Exception as e: + logger.error(f"Failed to get display leaderboard: {e}") + return [] diff --git a/backend-lehrer/game/game_models.py b/backend-lehrer/game/game_models.py new file mode 100644 index 0000000..4258b2d --- /dev/null +++ b/backend-lehrer/game/game_models.py @@ -0,0 +1,322 @@ +# ============================================== +# Breakpilot Drive - Game API Models & Data +# ============================================== +# Pydantic models, difficulty mappings, and sample questions. +# Extracted from game_api.py for file-size compliance. + +from pydantic import BaseModel +from typing import List, Optional, Literal, Dict, Any +from datetime import datetime + + +# ============================================== +# Pydantic Models +# ============================================== + +class LearningLevel(BaseModel): + """Lernniveau eines Benutzers aus dem Breakpilot-System""" + user_id: str + overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6) + math_level: float + german_level: float + english_level: float + last_updated: datetime + + +class GameDifficulty(BaseModel): + """Spielschwierigkeit basierend auf Lernniveau""" + lane_speed: float # Geschwindigkeit in m/s + obstacle_frequency: float # Hindernisse pro Sekunde + power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1) + question_complexity: int # 1-5 + answer_time: int # Sekunden zum Antworten + hints_enabled: bool + speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version + + +class QuizQuestion(BaseModel): + """Quiz-Frage fuer das Spiel""" + id: str + question_text: str + audio_url: Optional[str] = None + options: List[str] # 2-4 Antwortmoeglichkeiten + correct_index: int # 0-3 + difficulty: int # 1-5 + subject: Literal["math", "german", "english", "general"] + grade_level: Optional[int] = None # 2-6 + # NEU: Quiz-Modus + quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an + visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus + time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick) + + +class QuizAnswer(BaseModel): + """Antwort auf eine Quiz-Frage""" + question_id: str + selected_index: int + answer_time_ms: int # Zeit bis zur Antwort in ms + was_correct: bool + + +class GameSession(BaseModel): + """Spielsession-Daten fuer Analytics""" + user_id: str + game_mode: Literal["video", "audio"] + duration_seconds: int + distance_traveled: float + score: int + questions_answered: int + questions_correct: int + difficulty_level: int + quiz_answers: Optional[List[QuizAnswer]] = None + + +class SessionResponse(BaseModel): + """Antwort nach Session-Speicherung""" + session_id: str + status: str + new_level: Optional[int] = None # Falls Lernniveau angepasst wurde + + +# ============================================== +# Schwierigkeits-Mapping +# ============================================== + +DIFFICULTY_MAPPING = { + 1: GameDifficulty( + lane_speed=3.0, + obstacle_frequency=0.3, + power_up_chance=0.4, + question_complexity=1, + answer_time=15, + hints_enabled=True, + speech_speed=0.8 + ), + 2: GameDifficulty( + lane_speed=4.0, + obstacle_frequency=0.4, + power_up_chance=0.35, + question_complexity=2, + answer_time=12, + hints_enabled=True, + speech_speed=0.9 + ), + 3: GameDifficulty( + lane_speed=5.0, + obstacle_frequency=0.5, + power_up_chance=0.3, + question_complexity=3, + answer_time=10, + hints_enabled=True, + speech_speed=1.0 + ), + 4: GameDifficulty( + lane_speed=6.0, + obstacle_frequency=0.6, + power_up_chance=0.25, + question_complexity=4, + answer_time=8, + hints_enabled=False, + speech_speed=1.1 + ), + 5: GameDifficulty( + lane_speed=7.0, + obstacle_frequency=0.7, + power_up_chance=0.2, + question_complexity=5, + answer_time=6, + hints_enabled=False, + speech_speed=1.2 + ), +} + + +# ============================================== +# Beispiel Quiz-Fragen (spaeter aus DB laden) +# ============================================== + +SAMPLE_QUESTIONS = [ + # ============================================== + # QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert) + # ============================================== + + # Englisch Vokabeln - Objekte im Spiel (QUICK MODE) + QuizQuestion( + id="vq-bridge", question_text="What is this?", + options=["Bridge", "House"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-tree", question_text="What is this?", + options=["Tree", "Flower"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-house", question_text="What is this?", + options=["House", "Car"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-car", question_text="What is this?", + options=["Car", "Bus"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5 + ), + QuizQuestion( + id="vq-mountain", question_text="What is this?", + options=["Hill", "Mountain", "Valley"], correct_index=1, + difficulty=2, subject="english", grade_level=4, + quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5 + ), + QuizQuestion( + id="vq-river", question_text="What is this?", + options=["Lake", "River", "Sea"], correct_index=1, + difficulty=2, subject="english", grade_level=4, + quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5 + ), + + # Schnelle Rechenaufgaben (QUICK MODE) + QuizQuestion( + id="mq-1", question_text="3 + 4 = ?", + options=["6", "7"], correct_index=1, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=4.0 + ), + QuizQuestion( + id="mq-2", question_text="5 x 2 = ?", + options=["10", "12"], correct_index=0, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=4.0 + ), + QuizQuestion( + id="mq-3", question_text="8 - 3 = ?", + options=["4", "5"], correct_index=1, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=3.5 + ), + QuizQuestion( + id="mq-4", question_text="6 x 7 = ?", + options=["42", "48"], correct_index=0, + difficulty=2, subject="math", grade_level=3, + quiz_mode="quick", time_limit_seconds=5.0 + ), + QuizQuestion( + id="mq-5", question_text="9 x 8 = ?", + options=["72", "64"], correct_index=0, + difficulty=3, subject="math", grade_level=4, + quiz_mode="quick", time_limit_seconds=5.0 + ), + + # ============================================== + # PAUSE QUESTIONS (Spiel haelt an, mehr Zeit) + # ============================================== + + # Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE + QuizQuestion( + id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?", + options=["6", "7", "8", "9"], correct_index=2, + difficulty=1, subject="math", grade_level=2, + quiz_mode="pause" + ), + QuizQuestion( + id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?", + options=["4", "5", "6", "7"], correct_index=2, + difficulty=2, subject="math", grade_level=3, + quiz_mode="pause" + ), + QuizQuestion( + id="mp2-2", question_text="Was ist 45 + 27?", + options=["72", "62", "82", "70"], correct_index=0, + difficulty=2, subject="math", grade_level=3, + quiz_mode="pause" + ), + + # Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE + QuizQuestion( + id="mp3-1", question_text="Was ist 7 x 8?", + options=["54", "56", "58", "48"], correct_index=1, + difficulty=3, subject="math", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?", + options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1, + difficulty=3, subject="math", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="mp4-1", question_text="Was ist 15% von 80?", + options=["10", "12", "8", "15"], correct_index=1, + difficulty=4, subject="math", grade_level=5, + quiz_mode="pause" + ), + QuizQuestion( + id="mp4-2", question_text="Was ist 3/4 + 1/2?", + options=["5/4", "4/6", "1", "5/6"], correct_index=0, + difficulty=4, subject="math", grade_level=5, + quiz_mode="pause" + ), + + # Mathe Level 5 (Klasse 6) - PAUSE MODE + QuizQuestion( + id="mp5-1", question_text="Was ist (-5) x (-3)?", + options=["-15", "15", "-8", "8"], correct_index=1, + difficulty=5, subject="math", grade_level=6, + quiz_mode="pause" + ), + QuizQuestion( + id="mp5-2", question_text="Loesung von 2x + 5 = 11?", + options=["2", "3", "4", "6"], correct_index=1, + difficulty=5, subject="math", grade_level=6, + quiz_mode="pause" + ), + + # Deutsch - PAUSE MODE (brauchen Lesezeit) + QuizQuestion( + id="dp1-1", question_text="Welches Wort ist ein Nomen?", + options=["laufen", "schnell", "Hund", "und"], correct_index=2, + difficulty=1, subject="german", grade_level=2, + quiz_mode="pause" + ), + QuizQuestion( + id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?", + options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1, + difficulty=2, subject="german", grade_level=3, + quiz_mode="pause" + ), + QuizQuestion( + id="dp3-1", question_text="Welches Verb steht im Praeteritum?", + options=["geht", "ging", "gegangen", "gehen"], correct_index=1, + difficulty=3, subject="german", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'", + options=["Hund", "leuft", "schnell", "Der"], correct_index=1, + difficulty=3, subject="german", grade_level=4, + quiz_mode="pause" + ), + + # Englisch Saetze - PAUSE MODE + QuizQuestion( + id="ep3-1", question_text="How do you say 'Schmetterling'?", + options=["bird", "bee", "butterfly", "beetle"], correct_index=2, + difficulty=3, subject="english", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="ep4-1", question_text="Choose the correct form: She ___ to school.", + options=["go", "goes", "going", "gone"], correct_index=1, + difficulty=4, subject="english", grade_level=5, + quiz_mode="pause" + ), + QuizQuestion( + id="ep4-2", question_text="What is the past tense of 'run'?", + options=["runned", "ran", "runed", "running"], correct_index=1, + difficulty=4, subject="english", grade_level=5, + quiz_mode="pause" + ), +] diff --git a/backend-lehrer/game/routes.py b/backend-lehrer/game/routes.py new file mode 100644 index 0000000..8d1c02c --- /dev/null +++ b/backend-lehrer/game/routes.py @@ -0,0 +1,296 @@ +# ============================================== +# Breakpilot Drive - Game API Core Routes +# ============================================== +# Core game endpoints: learning level, difficulty, quiz questions. +# Session/stats/leaderboard routes are in game_session_routes.py. +# Extracted from game_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +from datetime import datetime +import random +import uuid +import os +import logging + +from .game_models import ( + LearningLevel, + GameDifficulty, + QuizQuestion, + QuizAnswer, + GameSession, + SessionResponse, + DIFFICULTY_MAPPING, + SAMPLE_QUESTIONS, +) + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" + +router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) + + +# ============================================== +# Auth Dependency (Optional) +# ============================================== + +async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: + """ + Optional auth dependency for Game API. + + If GAME_REQUIRE_AUTH=true: Requires valid JWT token + If GAME_REQUIRE_AUTH=false: Returns None (anonymous access) + + In development mode without auth, returns demo user. + """ + if not REQUIRE_AUTH: + return None + + try: + from auth import get_current_user + return await get_current_user(request) + except ImportError: + logger.warning("Auth module not available") + return None + except HTTPException: + raise # Re-raise auth errors + except Exception as e: + logger.error(f"Auth error: {e}") + raise HTTPException(status_code=401, detail="Authentication failed") + + +def get_user_id_from_auth( + user: Optional[Dict[str, Any]], + requested_user_id: str +) -> str: + """ + Get the effective user ID, respecting auth when enabled. + + If auth is enabled and user is authenticated: + - Returns user's own ID if requested_user_id matches + - For parents: allows access to child IDs from token + - For teachers: allows access to student IDs (future) + + If auth is disabled: Returns requested_user_id as-is + """ + if not REQUIRE_AUTH or user is None: + return requested_user_id + + user_id = user.get("user_id", "") + + # Same user - always allowed + if requested_user_id == user_id: + return user_id + + # Check for parent accessing child data + children_ids = user.get("raw_claims", {}).get("children_ids", []) + if requested_user_id in children_ids: + return requested_user_id + + # Check for teacher accessing student data (future) + realm_roles = user.get("realm_roles", []) + if "lehrer" in realm_roles or "teacher" in realm_roles: + # Teachers can access any student in their class (implement class check later) + return requested_user_id + + # Admin bypass + if "admin" in realm_roles: + return requested_user_id + + # Not authorized + raise HTTPException( + status_code=403, + detail="Not authorized to access this user's data" + ) + + +# In-Memory Session Storage (Fallback wenn DB nicht verfuegbar) +_sessions: dict[str, GameSession] = {} +_user_levels: dict[str, LearningLevel] = {} + +# Database integration +_game_db = None + +async def get_game_database(): + """Get game database instance with lazy initialization.""" + global _game_db + if not USE_DATABASE: + return None + if _game_db is None: + try: + from game.database import get_game_db + _game_db = await get_game_db() + logger.info("Game database initialized") + except Exception as e: + logger.warning(f"Game database not available, using in-memory: {e}") + return _game_db + + +# ============================================== +# API Endpunkte +# ============================================== + +@router.get("/learning-level/{user_id}", response_model=LearningLevel) +async def get_learning_level( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> LearningLevel: + """ + Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot. + + - Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen + - Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten) + - Cached Werte fuer schnellen Zugriff + - Speichert in PostgreSQL wenn verfuegbar + - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + # Try database first + db = await get_game_database() + if db: + state = await db.get_learning_state(user_id) + if state: + return LearningLevel( + user_id=user_id, + overall_level=state.overall_level, + math_level=state.math_level, + german_level=state.german_level, + english_level=state.english_level, + last_updated=state.updated_at or datetime.now() + ) + + # Create new state in database + new_state = await db.create_or_update_learning_state( + student_id=user_id, + overall_level=3, + math_level=3.0, + german_level=3.0, + english_level=3.0 + ) + if new_state: + return LearningLevel( + user_id=user_id, + overall_level=new_state.overall_level, + math_level=new_state.math_level, + german_level=new_state.german_level, + english_level=new_state.english_level, + last_updated=new_state.updated_at or datetime.now() + ) + + # Fallback to in-memory + if user_id in _user_levels: + return _user_levels[user_id] + + # Standard-Level fuer neue Benutzer + default_level = LearningLevel( + user_id=user_id, + overall_level=3, # Mittleres Level als Default + math_level=3.0, + german_level=3.0, + english_level=3.0, + last_updated=datetime.now() + ) + _user_levels[user_id] = default_level + return default_level + + +@router.get("/difficulty/{level}", response_model=GameDifficulty) +async def get_game_difficulty(level: int) -> GameDifficulty: + """ + Gibt Spielparameter basierend auf Lernniveau zurueck. + + Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz, + Fragen-Schwierigkeit etc. gemappt. + """ + if level < 1 or level > 5: + raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein") + + return DIFFICULTY_MAPPING[level] + + +@router.get("/quiz/questions", response_model=List[QuizQuestion]) +async def get_quiz_questions( + difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), + count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"), + subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"), + mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide") +) -> List[QuizQuestion]: + """ + Holt Quiz-Fragen fuer das Spiel. + + - Filtert nach Schwierigkeitsgrad (+/- 1 Level) + - Optional nach Fach filterbar + - Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben) + - Gibt zufaellige Auswahl zurueck + """ + # Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz) + filtered = [ + q for q in SAMPLE_QUESTIONS + if abs(q.difficulty - difficulty) <= 1 + and (subject is None or q.subject == subject) + and (mode is None or q.quiz_mode == mode) + ] + + if not filtered: + # Fallback: Alle Fragen wenn keine passenden gefunden + filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode] + + # Zufaellige Auswahl + selected = random.sample(filtered, min(count, len(filtered))) + return selected + + +@router.get("/quiz/visual-triggers") +async def get_visual_triggers() -> List[dict]: + """ + Gibt alle verfuegbaren visuellen Trigger zurueck. + + Unity verwendet diese Liste um zu wissen, welche Objekte + im Spiel Quiz-Fragen ausloesen koennen. + """ + triggers = {} + for q in SAMPLE_QUESTIONS: + if q.visual_trigger and q.quiz_mode == "quick": + if q.visual_trigger not in triggers: + triggers[q.visual_trigger] = { + "trigger": q.visual_trigger, + "question_count": 0, + "difficulties": set(), + "subjects": set() + } + triggers[q.visual_trigger]["question_count"] += 1 + triggers[q.visual_trigger]["difficulties"].add(q.difficulty) + triggers[q.visual_trigger]["subjects"].add(q.subject) + + # Sets zu Listen konvertieren fuer JSON + return [ + { + "trigger": t["trigger"], + "question_count": t["question_count"], + "difficulties": list(t["difficulties"]), + "subjects": list(t["subjects"]) + } + for t in triggers.values() + ] + + +@router.post("/quiz/answer") +async def submit_quiz_answer(answer: QuizAnswer) -> dict: + """ + Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback). + + In der finalen Version: Speichert in Session, updated Analytics. + """ + return { + "question_id": answer.question_id, + "was_correct": answer.was_correct, + "points": 500 if answer.was_correct else -100, + "message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!" + } + + diff --git a/backend-lehrer/game/session_routes.py b/backend-lehrer/game/session_routes.py new file mode 100644 index 0000000..912c0f4 --- /dev/null +++ b/backend-lehrer/game/session_routes.py @@ -0,0 +1,395 @@ +# ============================================== +# Breakpilot Drive - Game Session & Stats Routes +# ============================================== +# Session saving, leaderboard, stats, suggestions, +# quiz generation, and health check. +# Extracted from game_routes.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +from datetime import datetime +import uuid +import logging + +from .game_models import ( + LearningLevel, + QuizQuestion, + GameSession, + SessionResponse, + SAMPLE_QUESTIONS, +) + +logger = logging.getLogger(__name__) + +# Import shared state and helpers from game_routes +# (these are the canonical instances) +from .routes import ( + get_optional_current_user, + get_user_id_from_auth, + get_game_database, + get_quiz_questions, + _sessions, + _user_levels, + REQUIRE_AUTH, +) + +router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) + + +@router.post("/session", response_model=SessionResponse) +async def save_game_session( + session: GameSession, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> SessionResponse: + """ + Speichert eine komplette Spielsession. + + - Protokolliert Score, Distanz, Fragen-Performance + - Aktualisiert Lernniveau bei genuegend Daten + - Wird am Ende jedes Spiels aufgerufen + - Speichert in PostgreSQL wenn verfuegbar + - Bei GAME_REQUIRE_AUTH=true: User-ID aus Token + """ + # If auth is enabled, use user_id from token (ignore session.user_id) + effective_user_id = session.user_id + if REQUIRE_AUTH and user: + effective_user_id = user.get("user_id", session.user_id) + + session_id = str(uuid.uuid4()) + + # Lernniveau-Anpassung basierend auf Performance + new_level = None + old_level = 3 # Default + + # Try to get current level first + db = await get_game_database() + if db: + state = await db.get_learning_state(effective_user_id) + if state: + old_level = state.overall_level + else: + # Create initial state if not exists + await db.create_or_update_learning_state(effective_user_id) + old_level = 3 + elif effective_user_id in _user_levels: + old_level = _user_levels[effective_user_id].overall_level + + # Calculate level adjustment + if session.questions_answered >= 5: + accuracy = session.questions_correct / session.questions_answered + + # Anpassung: Wenn >80% korrekt und max nicht erreicht -> Level up + if accuracy >= 0.8 and old_level < 5: + new_level = old_level + 1 + # Wenn <40% korrekt und min nicht erreicht -> Level down + elif accuracy < 0.4 and old_level > 1: + new_level = old_level - 1 + + # Save to database + if db: + # Save session + db_session_id = await db.save_game_session( + student_id=effective_user_id, + game_mode=session.game_mode, + duration_seconds=session.duration_seconds, + distance_traveled=session.distance_traveled, + score=session.score, + questions_answered=session.questions_answered, + questions_correct=session.questions_correct, + difficulty_level=session.difficulty_level, + ) + if db_session_id: + session_id = db_session_id + + # Save individual quiz answers if provided + if session.quiz_answers: + for answer in session.quiz_answers: + await db.save_quiz_answer( + session_id=session_id, + question_id=answer.question_id, + subject="general", # Could be enhanced to track actual subject + difficulty=session.difficulty_level, + is_correct=answer.was_correct, + answer_time_ms=answer.answer_time_ms, + ) + + # Update learning stats + duration_minutes = session.duration_seconds // 60 + await db.update_learning_stats( + student_id=effective_user_id, + duration_minutes=duration_minutes, + questions_answered=session.questions_answered, + questions_correct=session.questions_correct, + new_level=new_level, + ) + else: + # Fallback to in-memory + _sessions[session_id] = session + + if new_level: + _user_levels[effective_user_id] = LearningLevel( + user_id=effective_user_id, + overall_level=new_level, + math_level=float(new_level), + german_level=float(new_level), + english_level=float(new_level), + last_updated=datetime.now() + ) + + return SessionResponse( + session_id=session_id, + status="saved", + new_level=new_level + ) + + +@router.get("/sessions/{user_id}") +async def get_user_sessions( + user_id: str, + limit: int = Query(10, ge=1, le=100), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[dict]: + """ + Holt die letzten Spielsessions eines Benutzers. + + Fuer Statistiken und Fortschrittsanzeige. + Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + # Try database first + db = await get_game_database() + if db: + sessions = await db.get_user_sessions(user_id, limit) + if sessions: + return sessions + + # Fallback to in-memory + user_sessions = [ + {"session_id": sid, **s.model_dump()} + for sid, s in _sessions.items() + if s.user_id == user_id + ] + return user_sessions[:limit] + + +@router.get("/leaderboard") +async def get_leaderboard( + timeframe: str = Query("day", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=100) +) -> List[dict]: + """ + Gibt Highscore-Liste zurueck. + + - Sortiert nach Punktzahl + - Optional nach Zeitraum filterbar + """ + # Try database first + db = await get_game_database() + if db: + leaderboard = await db.get_leaderboard(timeframe, limit) + if leaderboard: + return leaderboard + + # Fallback to in-memory + # Aggregiere Scores pro User + user_scores: dict[str, int] = {} + for session in _sessions.values(): + if session.user_id not in user_scores: + user_scores[session.user_id] = 0 + user_scores[session.user_id] += session.score + + # Sortieren und limitieren + leaderboard = [ + {"rank": i + 1, "user_id": uid, "total_score": score} + for i, (uid, score) in enumerate( + sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit] + ) + ] + + return leaderboard + + +@router.get("/stats/{user_id}") +async def get_user_stats( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt detaillierte Statistiken fuer einen Benutzer zurueck. + + - Gesamtstatistiken + - Fach-spezifische Statistiken + - Lernniveau-Verlauf + - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if db: + state = await db.get_learning_state(user_id) + subject_stats = await db.get_subject_stats(user_id) + + if state: + return { + "user_id": user_id, + "overall_level": state.overall_level, + "math_level": state.math_level, + "german_level": state.german_level, + "english_level": state.english_level, + "total_play_time_minutes": state.total_play_time_minutes, + "total_sessions": state.total_sessions, + "questions_answered": state.questions_answered, + "questions_correct": state.questions_correct, + "accuracy": state.accuracy, + "subjects": subject_stats, + } + + # Fallback - return defaults + return { + "user_id": user_id, + "overall_level": 3, + "math_level": 3.0, + "german_level": 3.0, + "english_level": 3.0, + "total_play_time_minutes": 0, + "total_sessions": 0, + "questions_answered": 0, + "questions_correct": 0, + "accuracy": 0.0, + "subjects": {}, + } + + +@router.get("/suggestions/{user_id}") +async def get_learning_suggestions( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck. + + Basierend auf aktueller Performance und Lernhistorie. + Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"suggestions": [], "message": "Database not available"} + + state = await db.get_learning_state(user_id) + if not state: + return {"suggestions": [], "message": "No learning state found"} + + try: + from game.learning_rules import ( + LearningContext, + get_rule_engine, + ) + + # Create context from state + context = LearningContext.from_learning_state(state) + + # Get suggestions from rule engine + engine = get_rule_engine() + suggestions = engine.evaluate(context) + + return { + "user_id": user_id, + "overall_level": state.overall_level, + "suggestions": [ + { + "title": s.title, + "description": s.description, + "action": s.action.value, + "priority": s.priority.name.lower(), + "metadata": s.metadata or {}, + } + for s in suggestions[:3] # Top 3 suggestions + ] + } + except ImportError: + return {"suggestions": [], "message": "Learning rules not available"} + except Exception as e: + logger.warning(f"Failed to get suggestions: {e}") + return {"suggestions": [], "message": str(e)} + + +@router.get("/quiz/generate") +async def generate_quiz_questions( + difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), + count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"), + subject: Optional[str] = Query(None, description="Fach: math, german, english"), + mode: str = Query("quick", description="Quiz-Modus: quick oder pause"), + visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.") +) -> List[dict]: + """ + Generiert Quiz-Fragen dynamisch via LLM. + + Fallback auf statische Fragen wenn LLM nicht verfuegbar. + """ + try: + from game.quiz_generator import get_quiz_generator + + generator = await get_quiz_generator() + questions = await generator.get_questions( + difficulty=difficulty, + subject=subject or "general", + mode=mode, + count=count, + visual_trigger=visual_trigger + ) + + if questions: + return [ + { + "id": f"gen-{i}", + "question_text": q.question_text, + "options": q.options, + "correct_index": q.correct_index, + "difficulty": q.difficulty, + "subject": q.subject, + "grade_level": q.grade_level, + "quiz_mode": q.quiz_mode, + "visual_trigger": q.visual_trigger, + "time_limit_seconds": q.time_limit_seconds, + } + for i, q in enumerate(questions) + ] + except ImportError: + logger.info("Quiz generator not available, using static questions") + except Exception as e: + logger.warning(f"Quiz generation failed: {e}") + + # Fallback to static questions + return await get_quiz_questions(difficulty, count, subject, mode) + + +@router.get("/health") +async def health_check() -> dict: + """Health-Check fuer das Spiel-Backend.""" + db = await get_game_database() + db_status = "connected" if db and db._connected else "disconnected" + + # Check LLM availability + llm_status = "disabled" + try: + from game.quiz_generator import get_quiz_generator + generator = await get_quiz_generator() + llm_status = "connected" if generator._llm_available else "disconnected" + except: + pass + + return { + "status": "healthy", + "service": "breakpilot-drive", + "database": db_status, + "llm_generator": llm_status, + "auth_required": REQUIRE_AUTH, + "questions_available": len(SAMPLE_QUESTIONS), + "active_sessions": len(_sessions) + } diff --git a/backend-lehrer/game_api.py b/backend-lehrer/game_api.py index ae71a5e..e16f085 100644 --- a/backend-lehrer/game_api.py +++ b/backend-lehrer/game_api.py @@ -1,46 +1,4 @@ -# ============================================== -# Breakpilot Drive - Game API (barrel re-export) -# ============================================== -# This module was split into: -# - game_models.py (Pydantic models, difficulty mapping, sample questions) -# - game_routes.py (Core game routes: level, quiz, session, leaderboard) -# - game_extended_routes.py (Phase 5: achievements, progress, parent, class) -# -# The `router` object is assembled here by including all sub-routers. -# Importers that did `from game_api import router` continue to work. - -from fastapi import APIRouter - -from game_routes import router as _core_router -from game_session_routes import router as _session_router -from game_extended_routes import router as _extended_router - -# Re-export models for any direct importers -from game_models import ( # noqa: F401 - LearningLevel, - GameDifficulty, - QuizQuestion, - QuizAnswer, - GameSession, - SessionResponse, - DIFFICULTY_MAPPING, - SAMPLE_QUESTIONS, -) - -# Re-export helpers/state for any direct importers -from game_routes import ( # noqa: F401 - get_optional_current_user, - get_user_id_from_auth, - get_game_database, - _sessions, - _user_levels, - USE_DATABASE, - REQUIRE_AUTH, -) - -# Assemble the combined router. -# Both sub-routers use prefix="/api/game", so include without extra prefix. -router = APIRouter() -router.include_router(_core_router) -router.include_router(_session_router) -router.include_router(_extended_router) +# Backward-compat shim -- module moved to game/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("game.api") diff --git a/backend-lehrer/game_extended_routes.py b/backend-lehrer/game_extended_routes.py index 513eefe..e747581 100644 --- a/backend-lehrer/game_extended_routes.py +++ b/backend-lehrer/game_extended_routes.py @@ -1,189 +1,4 @@ -# ============================================== -# Breakpilot Drive - Game Extended Routes -# ============================================== -# Phase 5 features: achievements, progress, parent dashboard, -# class leaderboard, and display leaderboard. -# Extracted from game_api.py for file-size compliance. - -from fastapi import APIRouter, HTTPException, Query, Depends, Request -from typing import List, Optional, Dict, Any -import logging - -from game_routes import ( - get_optional_current_user, - get_user_id_from_auth, - get_game_database, - REQUIRE_AUTH, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) - - -# ============================================== -# Phase 5: Erweiterte Features -# ============================================== - -@router.get("/achievements/{user_id}") -async def get_achievements( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck. - - Achievements werden basierend auf Spielstatistiken berechnet. - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if not db: - return {"achievements": [], "message": "Database not available"} - - try: - achievements = await db.get_student_achievements(user_id) - - unlocked = [a for a in achievements if a.unlocked] - locked = [a for a in achievements if not a.unlocked] - - return { - "user_id": user_id, - "total": len(achievements), - "unlocked_count": len(unlocked), - "achievements": [ - { - "id": a.id, - "name": a.name, - "description": a.description, - "icon": a.icon, - "category": a.category, - "threshold": a.threshold, - "progress": a.progress, - "unlocked": a.unlocked, - } - for a in achievements - ] - } - except Exception as e: - logger.error(f"Failed to get achievements: {e}") - return {"achievements": [], "message": str(e)} - - -@router.get("/progress/{user_id}") -async def get_progress( - user_id: str, - days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts). - - - Taegliche Statistiken - - Fuer Eltern-Dashboard und Fortschrittsanzeige - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if not db: - return {"progress": [], "message": "Database not available"} - - try: - progress = await db.get_progress_over_time(user_id, days) - return { - "user_id": user_id, - "days": days, - "data_points": len(progress), - "progress": progress, - } - except Exception as e: - logger.error(f"Failed to get progress: {e}") - return {"progress": [], "message": str(e)} - - -@router.get("/parent/children") -async def get_children_dashboard( - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Eltern-Dashboard: Statistiken fuer alle Kinder. - - Erfordert Auth mit Eltern-Rolle und children_ids Claim. - """ - if not REQUIRE_AUTH or user is None: - return { - "message": "Auth required for parent dashboard", - "children": [] - } - - # Get children IDs from token - children_ids = user.get("raw_claims", {}).get("children_ids", []) - - if not children_ids: - return { - "message": "No children associated with this account", - "children": [] - } - - db = await get_game_database() - if not db: - return {"children": [], "message": "Database not available"} - - try: - children_stats = await db.get_children_stats(children_ids) - return { - "parent_id": user.get("user_id"), - "children_count": len(children_ids), - "children": children_stats, - } - except Exception as e: - logger.error(f"Failed to get children stats: {e}") - return {"children": [], "message": str(e)} - - -@router.get("/leaderboard/class/{class_id}") -async def get_class_leaderboard( - class_id: str, - timeframe: str = Query("week", description="day, week, month, all"), - limit: int = Query(10, ge=1, le=50), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[dict]: - """ - Klassenspezifische Rangliste. - - Nur fuer Lehrer oder Schueler der Klasse sichtbar. - """ - db = await get_game_database() - if not db: - return [] - - try: - leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit) - return leaderboard - except Exception as e: - logger.error(f"Failed to get class leaderboard: {e}") - return [] - - -@router.get("/leaderboard/display") -async def get_display_leaderboard( - timeframe: str = Query("day", description="day, week, month, all"), - limit: int = Query(10, ge=1, le=100), - anonymize: bool = Query(True, description="Namen anonymisieren") -) -> List[dict]: - """ - Oeffentliche Rangliste mit Anzeigenamen. - - Standardmaessig anonymisiert fuer Datenschutz. - """ - db = await get_game_database() - if not db: - return [] - - try: - return await db.get_leaderboard_with_names(timeframe, limit, anonymize) - except Exception as e: - logger.error(f"Failed to get display leaderboard: {e}") - return [] +# Backward-compat shim -- module moved to game/extended_routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("game.extended_routes") diff --git a/backend-lehrer/game_models.py b/backend-lehrer/game_models.py index 4258b2d..84f963b 100644 --- a/backend-lehrer/game_models.py +++ b/backend-lehrer/game_models.py @@ -1,322 +1,4 @@ -# ============================================== -# Breakpilot Drive - Game API Models & Data -# ============================================== -# Pydantic models, difficulty mappings, and sample questions. -# Extracted from game_api.py for file-size compliance. - -from pydantic import BaseModel -from typing import List, Optional, Literal, Dict, Any -from datetime import datetime - - -# ============================================== -# Pydantic Models -# ============================================== - -class LearningLevel(BaseModel): - """Lernniveau eines Benutzers aus dem Breakpilot-System""" - user_id: str - overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6) - math_level: float - german_level: float - english_level: float - last_updated: datetime - - -class GameDifficulty(BaseModel): - """Spielschwierigkeit basierend auf Lernniveau""" - lane_speed: float # Geschwindigkeit in m/s - obstacle_frequency: float # Hindernisse pro Sekunde - power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1) - question_complexity: int # 1-5 - answer_time: int # Sekunden zum Antworten - hints_enabled: bool - speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version - - -class QuizQuestion(BaseModel): - """Quiz-Frage fuer das Spiel""" - id: str - question_text: str - audio_url: Optional[str] = None - options: List[str] # 2-4 Antwortmoeglichkeiten - correct_index: int # 0-3 - difficulty: int # 1-5 - subject: Literal["math", "german", "english", "general"] - grade_level: Optional[int] = None # 2-6 - # NEU: Quiz-Modus - quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an - visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus - time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick) - - -class QuizAnswer(BaseModel): - """Antwort auf eine Quiz-Frage""" - question_id: str - selected_index: int - answer_time_ms: int # Zeit bis zur Antwort in ms - was_correct: bool - - -class GameSession(BaseModel): - """Spielsession-Daten fuer Analytics""" - user_id: str - game_mode: Literal["video", "audio"] - duration_seconds: int - distance_traveled: float - score: int - questions_answered: int - questions_correct: int - difficulty_level: int - quiz_answers: Optional[List[QuizAnswer]] = None - - -class SessionResponse(BaseModel): - """Antwort nach Session-Speicherung""" - session_id: str - status: str - new_level: Optional[int] = None # Falls Lernniveau angepasst wurde - - -# ============================================== -# Schwierigkeits-Mapping -# ============================================== - -DIFFICULTY_MAPPING = { - 1: GameDifficulty( - lane_speed=3.0, - obstacle_frequency=0.3, - power_up_chance=0.4, - question_complexity=1, - answer_time=15, - hints_enabled=True, - speech_speed=0.8 - ), - 2: GameDifficulty( - lane_speed=4.0, - obstacle_frequency=0.4, - power_up_chance=0.35, - question_complexity=2, - answer_time=12, - hints_enabled=True, - speech_speed=0.9 - ), - 3: GameDifficulty( - lane_speed=5.0, - obstacle_frequency=0.5, - power_up_chance=0.3, - question_complexity=3, - answer_time=10, - hints_enabled=True, - speech_speed=1.0 - ), - 4: GameDifficulty( - lane_speed=6.0, - obstacle_frequency=0.6, - power_up_chance=0.25, - question_complexity=4, - answer_time=8, - hints_enabled=False, - speech_speed=1.1 - ), - 5: GameDifficulty( - lane_speed=7.0, - obstacle_frequency=0.7, - power_up_chance=0.2, - question_complexity=5, - answer_time=6, - hints_enabled=False, - speech_speed=1.2 - ), -} - - -# ============================================== -# Beispiel Quiz-Fragen (spaeter aus DB laden) -# ============================================== - -SAMPLE_QUESTIONS = [ - # ============================================== - # QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert) - # ============================================== - - # Englisch Vokabeln - Objekte im Spiel (QUICK MODE) - QuizQuestion( - id="vq-bridge", question_text="What is this?", - options=["Bridge", "House"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0 - ), - QuizQuestion( - id="vq-tree", question_text="What is this?", - options=["Tree", "Flower"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0 - ), - QuizQuestion( - id="vq-house", question_text="What is this?", - options=["House", "Car"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0 - ), - QuizQuestion( - id="vq-car", question_text="What is this?", - options=["Car", "Bus"], correct_index=0, - difficulty=1, subject="english", grade_level=3, - quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5 - ), - QuizQuestion( - id="vq-mountain", question_text="What is this?", - options=["Hill", "Mountain", "Valley"], correct_index=1, - difficulty=2, subject="english", grade_level=4, - quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5 - ), - QuizQuestion( - id="vq-river", question_text="What is this?", - options=["Lake", "River", "Sea"], correct_index=1, - difficulty=2, subject="english", grade_level=4, - quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5 - ), - - # Schnelle Rechenaufgaben (QUICK MODE) - QuizQuestion( - id="mq-1", question_text="3 + 4 = ?", - options=["6", "7"], correct_index=1, - difficulty=1, subject="math", grade_level=2, - quiz_mode="quick", time_limit_seconds=4.0 - ), - QuizQuestion( - id="mq-2", question_text="5 x 2 = ?", - options=["10", "12"], correct_index=0, - difficulty=1, subject="math", grade_level=2, - quiz_mode="quick", time_limit_seconds=4.0 - ), - QuizQuestion( - id="mq-3", question_text="8 - 3 = ?", - options=["4", "5"], correct_index=1, - difficulty=1, subject="math", grade_level=2, - quiz_mode="quick", time_limit_seconds=3.5 - ), - QuizQuestion( - id="mq-4", question_text="6 x 7 = ?", - options=["42", "48"], correct_index=0, - difficulty=2, subject="math", grade_level=3, - quiz_mode="quick", time_limit_seconds=5.0 - ), - QuizQuestion( - id="mq-5", question_text="9 x 8 = ?", - options=["72", "64"], correct_index=0, - difficulty=3, subject="math", grade_level=4, - quiz_mode="quick", time_limit_seconds=5.0 - ), - - # ============================================== - # PAUSE QUESTIONS (Spiel haelt an, mehr Zeit) - # ============================================== - - # Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE - QuizQuestion( - id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?", - options=["6", "7", "8", "9"], correct_index=2, - difficulty=1, subject="math", grade_level=2, - quiz_mode="pause" - ), - QuizQuestion( - id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?", - options=["4", "5", "6", "7"], correct_index=2, - difficulty=2, subject="math", grade_level=3, - quiz_mode="pause" - ), - QuizQuestion( - id="mp2-2", question_text="Was ist 45 + 27?", - options=["72", "62", "82", "70"], correct_index=0, - difficulty=2, subject="math", grade_level=3, - quiz_mode="pause" - ), - - # Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE - QuizQuestion( - id="mp3-1", question_text="Was ist 7 x 8?", - options=["54", "56", "58", "48"], correct_index=1, - difficulty=3, subject="math", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?", - options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1, - difficulty=3, subject="math", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="mp4-1", question_text="Was ist 15% von 80?", - options=["10", "12", "8", "15"], correct_index=1, - difficulty=4, subject="math", grade_level=5, - quiz_mode="pause" - ), - QuizQuestion( - id="mp4-2", question_text="Was ist 3/4 + 1/2?", - options=["5/4", "4/6", "1", "5/6"], correct_index=0, - difficulty=4, subject="math", grade_level=5, - quiz_mode="pause" - ), - - # Mathe Level 5 (Klasse 6) - PAUSE MODE - QuizQuestion( - id="mp5-1", question_text="Was ist (-5) x (-3)?", - options=["-15", "15", "-8", "8"], correct_index=1, - difficulty=5, subject="math", grade_level=6, - quiz_mode="pause" - ), - QuizQuestion( - id="mp5-2", question_text="Loesung von 2x + 5 = 11?", - options=["2", "3", "4", "6"], correct_index=1, - difficulty=5, subject="math", grade_level=6, - quiz_mode="pause" - ), - - # Deutsch - PAUSE MODE (brauchen Lesezeit) - QuizQuestion( - id="dp1-1", question_text="Welches Wort ist ein Nomen?", - options=["laufen", "schnell", "Hund", "und"], correct_index=2, - difficulty=1, subject="german", grade_level=2, - quiz_mode="pause" - ), - QuizQuestion( - id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?", - options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1, - difficulty=2, subject="german", grade_level=3, - quiz_mode="pause" - ), - QuizQuestion( - id="dp3-1", question_text="Welches Verb steht im Praeteritum?", - options=["geht", "ging", "gegangen", "gehen"], correct_index=1, - difficulty=3, subject="german", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'", - options=["Hund", "leuft", "schnell", "Der"], correct_index=1, - difficulty=3, subject="german", grade_level=4, - quiz_mode="pause" - ), - - # Englisch Saetze - PAUSE MODE - QuizQuestion( - id="ep3-1", question_text="How do you say 'Schmetterling'?", - options=["bird", "bee", "butterfly", "beetle"], correct_index=2, - difficulty=3, subject="english", grade_level=4, - quiz_mode="pause" - ), - QuizQuestion( - id="ep4-1", question_text="Choose the correct form: She ___ to school.", - options=["go", "goes", "going", "gone"], correct_index=1, - difficulty=4, subject="english", grade_level=5, - quiz_mode="pause" - ), - QuizQuestion( - id="ep4-2", question_text="What is the past tense of 'run'?", - options=["runned", "ran", "runed", "running"], correct_index=1, - difficulty=4, subject="english", grade_level=5, - quiz_mode="pause" - ), -] +# Backward-compat shim -- module moved to game/game_models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("game.game_models") diff --git a/backend-lehrer/game_routes.py b/backend-lehrer/game_routes.py index 21a1e4e..e87cc43 100644 --- a/backend-lehrer/game_routes.py +++ b/backend-lehrer/game_routes.py @@ -1,296 +1,4 @@ -# ============================================== -# Breakpilot Drive - Game API Core Routes -# ============================================== -# Core game endpoints: learning level, difficulty, quiz questions. -# Session/stats/leaderboard routes are in game_session_routes.py. -# Extracted from game_api.py for file-size compliance. - -from fastapi import APIRouter, HTTPException, Query, Depends, Request -from typing import List, Optional, Dict, Any -from datetime import datetime -import random -import uuid -import os -import logging - -from game_models import ( - LearningLevel, - GameDifficulty, - QuizQuestion, - QuizAnswer, - GameSession, - SessionResponse, - DIFFICULTY_MAPPING, - SAMPLE_QUESTIONS, -) - -logger = logging.getLogger(__name__) - -# Feature flags -USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" -REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" - -router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) - - -# ============================================== -# Auth Dependency (Optional) -# ============================================== - -async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: - """ - Optional auth dependency for Game API. - - If GAME_REQUIRE_AUTH=true: Requires valid JWT token - If GAME_REQUIRE_AUTH=false: Returns None (anonymous access) - - In development mode without auth, returns demo user. - """ - if not REQUIRE_AUTH: - return None - - try: - from auth import get_current_user - return await get_current_user(request) - except ImportError: - logger.warning("Auth module not available") - return None - except HTTPException: - raise # Re-raise auth errors - except Exception as e: - logger.error(f"Auth error: {e}") - raise HTTPException(status_code=401, detail="Authentication failed") - - -def get_user_id_from_auth( - user: Optional[Dict[str, Any]], - requested_user_id: str -) -> str: - """ - Get the effective user ID, respecting auth when enabled. - - If auth is enabled and user is authenticated: - - Returns user's own ID if requested_user_id matches - - For parents: allows access to child IDs from token - - For teachers: allows access to student IDs (future) - - If auth is disabled: Returns requested_user_id as-is - """ - if not REQUIRE_AUTH or user is None: - return requested_user_id - - user_id = user.get("user_id", "") - - # Same user - always allowed - if requested_user_id == user_id: - return user_id - - # Check for parent accessing child data - children_ids = user.get("raw_claims", {}).get("children_ids", []) - if requested_user_id in children_ids: - return requested_user_id - - # Check for teacher accessing student data (future) - realm_roles = user.get("realm_roles", []) - if "lehrer" in realm_roles or "teacher" in realm_roles: - # Teachers can access any student in their class (implement class check later) - return requested_user_id - - # Admin bypass - if "admin" in realm_roles: - return requested_user_id - - # Not authorized - raise HTTPException( - status_code=403, - detail="Not authorized to access this user's data" - ) - - -# In-Memory Session Storage (Fallback wenn DB nicht verfuegbar) -_sessions: dict[str, GameSession] = {} -_user_levels: dict[str, LearningLevel] = {} - -# Database integration -_game_db = None - -async def get_game_database(): - """Get game database instance with lazy initialization.""" - global _game_db - if not USE_DATABASE: - return None - if _game_db is None: - try: - from game.database import get_game_db - _game_db = await get_game_db() - logger.info("Game database initialized") - except Exception as e: - logger.warning(f"Game database not available, using in-memory: {e}") - return _game_db - - -# ============================================== -# API Endpunkte -# ============================================== - -@router.get("/learning-level/{user_id}", response_model=LearningLevel) -async def get_learning_level( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> LearningLevel: - """ - Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot. - - - Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen - - Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten) - - Cached Werte fuer schnellen Zugriff - - Speichert in PostgreSQL wenn verfuegbar - - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - # Try database first - db = await get_game_database() - if db: - state = await db.get_learning_state(user_id) - if state: - return LearningLevel( - user_id=user_id, - overall_level=state.overall_level, - math_level=state.math_level, - german_level=state.german_level, - english_level=state.english_level, - last_updated=state.updated_at or datetime.now() - ) - - # Create new state in database - new_state = await db.create_or_update_learning_state( - student_id=user_id, - overall_level=3, - math_level=3.0, - german_level=3.0, - english_level=3.0 - ) - if new_state: - return LearningLevel( - user_id=user_id, - overall_level=new_state.overall_level, - math_level=new_state.math_level, - german_level=new_state.german_level, - english_level=new_state.english_level, - last_updated=new_state.updated_at or datetime.now() - ) - - # Fallback to in-memory - if user_id in _user_levels: - return _user_levels[user_id] - - # Standard-Level fuer neue Benutzer - default_level = LearningLevel( - user_id=user_id, - overall_level=3, # Mittleres Level als Default - math_level=3.0, - german_level=3.0, - english_level=3.0, - last_updated=datetime.now() - ) - _user_levels[user_id] = default_level - return default_level - - -@router.get("/difficulty/{level}", response_model=GameDifficulty) -async def get_game_difficulty(level: int) -> GameDifficulty: - """ - Gibt Spielparameter basierend auf Lernniveau zurueck. - - Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz, - Fragen-Schwierigkeit etc. gemappt. - """ - if level < 1 or level > 5: - raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein") - - return DIFFICULTY_MAPPING[level] - - -@router.get("/quiz/questions", response_model=List[QuizQuestion]) -async def get_quiz_questions( - difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), - count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"), - subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"), - mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide") -) -> List[QuizQuestion]: - """ - Holt Quiz-Fragen fuer das Spiel. - - - Filtert nach Schwierigkeitsgrad (+/- 1 Level) - - Optional nach Fach filterbar - - Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben) - - Gibt zufaellige Auswahl zurueck - """ - # Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz) - filtered = [ - q for q in SAMPLE_QUESTIONS - if abs(q.difficulty - difficulty) <= 1 - and (subject is None or q.subject == subject) - and (mode is None or q.quiz_mode == mode) - ] - - if not filtered: - # Fallback: Alle Fragen wenn keine passenden gefunden - filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode] - - # Zufaellige Auswahl - selected = random.sample(filtered, min(count, len(filtered))) - return selected - - -@router.get("/quiz/visual-triggers") -async def get_visual_triggers() -> List[dict]: - """ - Gibt alle verfuegbaren visuellen Trigger zurueck. - - Unity verwendet diese Liste um zu wissen, welche Objekte - im Spiel Quiz-Fragen ausloesen koennen. - """ - triggers = {} - for q in SAMPLE_QUESTIONS: - if q.visual_trigger and q.quiz_mode == "quick": - if q.visual_trigger not in triggers: - triggers[q.visual_trigger] = { - "trigger": q.visual_trigger, - "question_count": 0, - "difficulties": set(), - "subjects": set() - } - triggers[q.visual_trigger]["question_count"] += 1 - triggers[q.visual_trigger]["difficulties"].add(q.difficulty) - triggers[q.visual_trigger]["subjects"].add(q.subject) - - # Sets zu Listen konvertieren fuer JSON - return [ - { - "trigger": t["trigger"], - "question_count": t["question_count"], - "difficulties": list(t["difficulties"]), - "subjects": list(t["subjects"]) - } - for t in triggers.values() - ] - - -@router.post("/quiz/answer") -async def submit_quiz_answer(answer: QuizAnswer) -> dict: - """ - Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback). - - In der finalen Version: Speichert in Session, updated Analytics. - """ - return { - "question_id": answer.question_id, - "was_correct": answer.was_correct, - "points": 500 if answer.was_correct else -100, - "message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!" - } - - +# Backward-compat shim -- module moved to game/routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("game.routes") diff --git a/backend-lehrer/game_session_routes.py b/backend-lehrer/game_session_routes.py index ef48ad8..8878e53 100644 --- a/backend-lehrer/game_session_routes.py +++ b/backend-lehrer/game_session_routes.py @@ -1,395 +1,4 @@ -# ============================================== -# Breakpilot Drive - Game Session & Stats Routes -# ============================================== -# Session saving, leaderboard, stats, suggestions, -# quiz generation, and health check. -# Extracted from game_routes.py for file-size compliance. - -from fastapi import APIRouter, HTTPException, Query, Depends, Request -from typing import List, Optional, Dict, Any -from datetime import datetime -import uuid -import logging - -from game_models import ( - LearningLevel, - QuizQuestion, - GameSession, - SessionResponse, - SAMPLE_QUESTIONS, -) - -logger = logging.getLogger(__name__) - -# Import shared state and helpers from game_routes -# (these are the canonical instances) -from game_routes import ( - get_optional_current_user, - get_user_id_from_auth, - get_game_database, - get_quiz_questions, - _sessions, - _user_levels, - REQUIRE_AUTH, -) - -router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) - - -@router.post("/session", response_model=SessionResponse) -async def save_game_session( - session: GameSession, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> SessionResponse: - """ - Speichert eine komplette Spielsession. - - - Protokolliert Score, Distanz, Fragen-Performance - - Aktualisiert Lernniveau bei genuegend Daten - - Wird am Ende jedes Spiels aufgerufen - - Speichert in PostgreSQL wenn verfuegbar - - Bei GAME_REQUIRE_AUTH=true: User-ID aus Token - """ - # If auth is enabled, use user_id from token (ignore session.user_id) - effective_user_id = session.user_id - if REQUIRE_AUTH and user: - effective_user_id = user.get("user_id", session.user_id) - - session_id = str(uuid.uuid4()) - - # Lernniveau-Anpassung basierend auf Performance - new_level = None - old_level = 3 # Default - - # Try to get current level first - db = await get_game_database() - if db: - state = await db.get_learning_state(effective_user_id) - if state: - old_level = state.overall_level - else: - # Create initial state if not exists - await db.create_or_update_learning_state(effective_user_id) - old_level = 3 - elif effective_user_id in _user_levels: - old_level = _user_levels[effective_user_id].overall_level - - # Calculate level adjustment - if session.questions_answered >= 5: - accuracy = session.questions_correct / session.questions_answered - - # Anpassung: Wenn >80% korrekt und max nicht erreicht -> Level up - if accuracy >= 0.8 and old_level < 5: - new_level = old_level + 1 - # Wenn <40% korrekt und min nicht erreicht -> Level down - elif accuracy < 0.4 and old_level > 1: - new_level = old_level - 1 - - # Save to database - if db: - # Save session - db_session_id = await db.save_game_session( - student_id=effective_user_id, - game_mode=session.game_mode, - duration_seconds=session.duration_seconds, - distance_traveled=session.distance_traveled, - score=session.score, - questions_answered=session.questions_answered, - questions_correct=session.questions_correct, - difficulty_level=session.difficulty_level, - ) - if db_session_id: - session_id = db_session_id - - # Save individual quiz answers if provided - if session.quiz_answers: - for answer in session.quiz_answers: - await db.save_quiz_answer( - session_id=session_id, - question_id=answer.question_id, - subject="general", # Could be enhanced to track actual subject - difficulty=session.difficulty_level, - is_correct=answer.was_correct, - answer_time_ms=answer.answer_time_ms, - ) - - # Update learning stats - duration_minutes = session.duration_seconds // 60 - await db.update_learning_stats( - student_id=effective_user_id, - duration_minutes=duration_minutes, - questions_answered=session.questions_answered, - questions_correct=session.questions_correct, - new_level=new_level, - ) - else: - # Fallback to in-memory - _sessions[session_id] = session - - if new_level: - _user_levels[effective_user_id] = LearningLevel( - user_id=effective_user_id, - overall_level=new_level, - math_level=float(new_level), - german_level=float(new_level), - english_level=float(new_level), - last_updated=datetime.now() - ) - - return SessionResponse( - session_id=session_id, - status="saved", - new_level=new_level - ) - - -@router.get("/sessions/{user_id}") -async def get_user_sessions( - user_id: str, - limit: int = Query(10, ge=1, le=100), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[dict]: - """ - Holt die letzten Spielsessions eines Benutzers. - - Fuer Statistiken und Fortschrittsanzeige. - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - # Try database first - db = await get_game_database() - if db: - sessions = await db.get_user_sessions(user_id, limit) - if sessions: - return sessions - - # Fallback to in-memory - user_sessions = [ - {"session_id": sid, **s.model_dump()} - for sid, s in _sessions.items() - if s.user_id == user_id - ] - return user_sessions[:limit] - - -@router.get("/leaderboard") -async def get_leaderboard( - timeframe: str = Query("day", description="day, week, month, all"), - limit: int = Query(10, ge=1, le=100) -) -> List[dict]: - """ - Gibt Highscore-Liste zurueck. - - - Sortiert nach Punktzahl - - Optional nach Zeitraum filterbar - """ - # Try database first - db = await get_game_database() - if db: - leaderboard = await db.get_leaderboard(timeframe, limit) - if leaderboard: - return leaderboard - - # Fallback to in-memory - # Aggregiere Scores pro User - user_scores: dict[str, int] = {} - for session in _sessions.values(): - if session.user_id not in user_scores: - user_scores[session.user_id] = 0 - user_scores[session.user_id] += session.score - - # Sortieren und limitieren - leaderboard = [ - {"rank": i + 1, "user_id": uid, "total_score": score} - for i, (uid, score) in enumerate( - sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit] - ) - ] - - return leaderboard - - -@router.get("/stats/{user_id}") -async def get_user_stats( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt detaillierte Statistiken fuer einen Benutzer zurueck. - - - Gesamtstatistiken - - Fach-spezifische Statistiken - - Lernniveau-Verlauf - - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if db: - state = await db.get_learning_state(user_id) - subject_stats = await db.get_subject_stats(user_id) - - if state: - return { - "user_id": user_id, - "overall_level": state.overall_level, - "math_level": state.math_level, - "german_level": state.german_level, - "english_level": state.english_level, - "total_play_time_minutes": state.total_play_time_minutes, - "total_sessions": state.total_sessions, - "questions_answered": state.questions_answered, - "questions_correct": state.questions_correct, - "accuracy": state.accuracy, - "subjects": subject_stats, - } - - # Fallback - return defaults - return { - "user_id": user_id, - "overall_level": 3, - "math_level": 3.0, - "german_level": 3.0, - "english_level": 3.0, - "total_play_time_minutes": 0, - "total_sessions": 0, - "questions_answered": 0, - "questions_correct": 0, - "accuracy": 0.0, - "subjects": {}, - } - - -@router.get("/suggestions/{user_id}") -async def get_learning_suggestions( - user_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> dict: - """ - Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck. - - Basierend auf aktueller Performance und Lernhistorie. - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. - """ - # Verify access rights - user_id = get_user_id_from_auth(user, user_id) - - db = await get_game_database() - if not db: - return {"suggestions": [], "message": "Database not available"} - - state = await db.get_learning_state(user_id) - if not state: - return {"suggestions": [], "message": "No learning state found"} - - try: - from game.learning_rules import ( - LearningContext, - get_rule_engine, - ) - - # Create context from state - context = LearningContext.from_learning_state(state) - - # Get suggestions from rule engine - engine = get_rule_engine() - suggestions = engine.evaluate(context) - - return { - "user_id": user_id, - "overall_level": state.overall_level, - "suggestions": [ - { - "title": s.title, - "description": s.description, - "action": s.action.value, - "priority": s.priority.name.lower(), - "metadata": s.metadata or {}, - } - for s in suggestions[:3] # Top 3 suggestions - ] - } - except ImportError: - return {"suggestions": [], "message": "Learning rules not available"} - except Exception as e: - logger.warning(f"Failed to get suggestions: {e}") - return {"suggestions": [], "message": str(e)} - - -@router.get("/quiz/generate") -async def generate_quiz_questions( - difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), - count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"), - subject: Optional[str] = Query(None, description="Fach: math, german, english"), - mode: str = Query("quick", description="Quiz-Modus: quick oder pause"), - visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.") -) -> List[dict]: - """ - Generiert Quiz-Fragen dynamisch via LLM. - - Fallback auf statische Fragen wenn LLM nicht verfuegbar. - """ - try: - from game.quiz_generator import get_quiz_generator - - generator = await get_quiz_generator() - questions = await generator.get_questions( - difficulty=difficulty, - subject=subject or "general", - mode=mode, - count=count, - visual_trigger=visual_trigger - ) - - if questions: - return [ - { - "id": f"gen-{i}", - "question_text": q.question_text, - "options": q.options, - "correct_index": q.correct_index, - "difficulty": q.difficulty, - "subject": q.subject, - "grade_level": q.grade_level, - "quiz_mode": q.quiz_mode, - "visual_trigger": q.visual_trigger, - "time_limit_seconds": q.time_limit_seconds, - } - for i, q in enumerate(questions) - ] - except ImportError: - logger.info("Quiz generator not available, using static questions") - except Exception as e: - logger.warning(f"Quiz generation failed: {e}") - - # Fallback to static questions - return await get_quiz_questions(difficulty, count, subject, mode) - - -@router.get("/health") -async def health_check() -> dict: - """Health-Check fuer das Spiel-Backend.""" - db = await get_game_database() - db_status = "connected" if db and db._connected else "disconnected" - - # Check LLM availability - llm_status = "disabled" - try: - from game.quiz_generator import get_quiz_generator - generator = await get_quiz_generator() - llm_status = "connected" if generator._llm_available else "disconnected" - except: - pass - - return { - "status": "healthy", - "service": "breakpilot-drive", - "database": db_status, - "llm_generator": llm_status, - "auth_required": REQUIRE_AUTH, - "questions_available": len(SAMPLE_QUESTIONS), - "active_sessions": len(_sessions) - } +# Backward-compat shim -- module moved to game/session_routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("game.session_routes") diff --git a/backend-lehrer/learning_units.py b/backend-lehrer/learning_units.py index 425ad2f..88d494b 100644 --- a/backend-lehrer/learning_units.py +++ b/backend-lehrer/learning_units.py @@ -1,178 +1,4 @@ -from __future__ import annotations -from pydantic import BaseModel, Field -from typing import List, Dict, Optional -from pathlib import Path -from datetime import datetime -import uuid -import json -import threading - -# Basisverzeichnis für Arbeitsblätter & Lerneinheiten -BASE_DIR = Path.home() / "Arbeitsblaetter" -LEARNING_UNITS_DIR = BASE_DIR / "Lerneinheiten" -LEARNING_UNITS_FILE = LEARNING_UNITS_DIR / "learning_units.json" - -# Thread-Lock, damit Dateizugriffe sicher bleiben -_lock = threading.Lock() - - -class LearningUnitBase(BaseModel): - title: str = Field(..., description="Titel der Lerneinheit, z.B. 'Das Auge – Klasse 7'") - description: Optional[str] = Field(None, description="Freitext-Beschreibung") - topic: Optional[str] = Field(None, description="Kurz-Thema, z.B. 'Auge'") - grade_level: Optional[str] = Field(None, description="Klassenstufe, z.B. '7'") - language: Optional[str] = Field("de", description="Hauptsprache der Lerneinheit (z.B. 'de', 'tr')") - worksheet_files: List[str] = Field( - default_factory=list, - description="Liste der zugeordneten Arbeitsblatt-Dateien (Basenames oder Pfade)" - ) - status: str = Field( - "raw", - description="Pipeline-Status: raw, cleaned, qa_generated, mc_generated, cloze_generated" - ) - - -class LearningUnitCreate(LearningUnitBase): - """Payload zum Erstellen einer neuen Lerneinheit.""" - pass - - -class LearningUnitUpdate(BaseModel): - """Teil-Update für eine Lerneinheit.""" - title: Optional[str] = None - description: Optional[str] = None - topic: Optional[str] = None - grade_level: Optional[str] = None - language: Optional[str] = None - worksheet_files: Optional[List[str]] = None - status: Optional[str] = None - - -class LearningUnit(LearningUnitBase): - id: str - created_at: datetime - updated_at: datetime - - @classmethod - def from_dict(cls, data: Dict) -> "LearningUnit": - data = data.copy() - if isinstance(data.get("created_at"), str): - data["created_at"] = datetime.fromisoformat(data["created_at"]) - if isinstance(data.get("updated_at"), str): - data["updated_at"] = datetime.fromisoformat(data["updated_at"]) - return cls(**data) - - def to_dict(self) -> Dict: - d = self.dict() - d["created_at"] = self.created_at.isoformat() - d["updated_at"] = self.updated_at.isoformat() - return d - - -def _ensure_storage(): - """Sorgt dafür, dass der Ordner und die JSON-Datei existieren.""" - LEARNING_UNITS_DIR.mkdir(parents=True, exist_ok=True) - if not LEARNING_UNITS_FILE.exists(): - with LEARNING_UNITS_FILE.open("w", encoding="utf-8") as f: - json.dump({}, f) - - -def _load_all_units() -> Dict[str, Dict]: - _ensure_storage() - with LEARNING_UNITS_FILE.open("r", encoding="utf-8") as f: - try: - data = json.load(f) - if not isinstance(data, dict): - return {} - return data - except json.JSONDecodeError: - return {} - - -def _save_all_units(raw: Dict[str, Dict]) -> None: - _ensure_storage() - with LEARNING_UNITS_FILE.open("w", encoding="utf-8") as f: - json.dump(raw, f, ensure_ascii=False, indent=2) - - -def list_learning_units() -> List[LearningUnit]: - with _lock: - raw = _load_all_units() - return [LearningUnit.from_dict(v) for v in raw.values()] - - -def get_learning_unit(unit_id: str) -> Optional[LearningUnit]: - with _lock: - raw = _load_all_units() - data = raw.get(unit_id) - if not data: - return None - return LearningUnit.from_dict(data) - - -def create_learning_unit(payload: LearningUnitCreate) -> LearningUnit: - now = datetime.utcnow() - lu = LearningUnit( - id=str(uuid.uuid4()), - created_at=now, - updated_at=now, - **payload.dict() - ) - with _lock: - raw = _load_all_units() - raw[lu.id] = lu.to_dict() - _save_all_units(raw) - return lu - - -def update_learning_unit(unit_id: str, payload: LearningUnitUpdate) -> Optional[LearningUnit]: - with _lock: - raw = _load_all_units() - existing = raw.get(unit_id) - if not existing: - return None - - lu = LearningUnit.from_dict(existing) - update_data = payload.dict(exclude_unset=True) - - for field, value in update_data.items(): - setattr(lu, field, value) - - lu.updated_at = datetime.utcnow() - raw[lu.id] = lu.to_dict() - _save_all_units(raw) - return lu - - -def delete_learning_unit(unit_id: str) -> bool: - with _lock: - raw = _load_all_units() - if unit_id not in raw: - return False - del raw[unit_id] - _save_all_units(raw) - return True - - -def attach_worksheets(unit_id: str, worksheet_files: List[str]) -> Optional[LearningUnit]: - """ - Hängt eine Liste von Arbeitsblatt-Dateien an eine bestehende Lerneinheit an. - Doppelte Einträge werden vermieden. - """ - with _lock: - raw = _load_all_units() - existing = raw.get(unit_id) - if not existing: - return None - - lu = LearningUnit.from_dict(existing) - current_set = set(lu.worksheet_files) - for f in worksheet_files: - current_set.add(f) - lu.worksheet_files = sorted(current_set) - lu.updated_at = datetime.utcnow() - - raw[lu.id] = lu.to_dict() - _save_all_units(raw) - return lu - +# Backward-compat shim -- module moved to units/learning.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.learning") diff --git a/backend-lehrer/learning_units_api.py b/backend-lehrer/learning_units_api.py index 7418e89..6cd9070 100644 --- a/backend-lehrer/learning_units_api.py +++ b/backend-lehrer/learning_units_api.py @@ -1,376 +1,4 @@ -from typing import List, Dict, Any, Optional -from datetime import datetime -from pathlib import Path -import json -import os -import logging - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from learning_units import ( - LearningUnit, - LearningUnitCreate, - LearningUnitUpdate, - list_learning_units, - get_learning_unit, - create_learning_unit, - update_learning_unit, - delete_learning_unit, -) - -logger = logging.getLogger(__name__) - - -router = APIRouter( - prefix="/learning-units", - tags=["learning-units"], -) - - -# ---------- Payload-Modelle für das Frontend ---------- - - -class LearningUnitCreatePayload(BaseModel): - """ - Payload so, wie er aus dem Frontend kommt: - { - "student": "...", - "subject": "...", - "title": "...", - "grade": "7a" - } - """ - student: Optional[str] = None - subject: Optional[str] = None - title: Optional[str] = None - grade: Optional[str] = None - - -class AttachWorksheetsPayload(BaseModel): - worksheet_files: List[str] - - -class RemoveWorksheetPayload(BaseModel): - worksheet_file: str - - -class GenerateFromAnalysisPayload(BaseModel): - analysis_data: Dict[str, Any] - num_questions: int = 8 - - -# ---------- Hilfsfunktion: Backend-Modell -> Frontend-Objekt ---------- - - -def unit_to_frontend_dict(lu: LearningUnit) -> Dict[str, Any]: - """ - Wandelt eine LearningUnit in das Format um, das das Frontend erwartet. - Wichtig sind: - - id - - label (sichtbarer Name) - - meta (Untertitelzeile) - - worksheet_files (Liste von Dateinamen) - """ - label = lu.title or "Lerneinheit" - - # Meta-Text: z.B. "Thema: Auge · Klasse: 7a · angelegt am 10.12.2025" - meta_parts: List[str] = [] - if lu.topic: - meta_parts.append(f"Thema: {lu.topic}") - if lu.grade_level: - meta_parts.append(f"Klasse: {lu.grade_level}") - created_str = lu.created_at.strftime("%d.%m.%Y") - meta_parts.append(f"angelegt am {created_str}") - - meta = " · ".join(meta_parts) - - return { - "id": lu.id, - "label": label, - "meta": meta, - "title": lu.title, - "topic": lu.topic, - "grade_level": lu.grade_level, - "language": lu.language, - "status": lu.status, - "worksheet_files": lu.worksheet_files, - "created_at": lu.created_at.isoformat(), - "updated_at": lu.updated_at.isoformat(), - } - - -# ---------- Endpunkte ---------- - - -@router.get("/", response_model=List[Dict[str, Any]]) -def api_list_learning_units(): - """Alle Lerneinheiten für das Frontend auflisten.""" - units = list_learning_units() - return [unit_to_frontend_dict(u) for u in units] - - -@router.post("/", response_model=Dict[str, Any]) -def api_create_learning_unit(payload: LearningUnitCreatePayload): - """ - Neue Lerneinheit anlegen. - Mapped das Frontend-Payload (student/subject/title/grade) - auf das generische LearningUnit-Modell. - """ - - # Mindestens eines der Felder muss gesetzt sein - if not (payload.student or payload.subject or payload.title): - raise HTTPException( - status_code=400, - detail="Bitte mindestens Schüler/in, Fach oder Thema angeben.", - ) - - # Titel/Topic bestimmen - # sichtbarer Titel: bevorzugt Thema (title), sonst Kombination - if payload.title: - title = payload.title - else: - parts = [] - if payload.subject: - parts.append(payload.subject) - if payload.student: - parts.append(payload.student) - title = " – ".join(parts) if parts else "Lerneinheit" - - topic = payload.title or payload.subject or None - grade_level = payload.grade or None - - lu_create = LearningUnitCreate( - title=title, - description=None, - topic=topic, - grade_level=grade_level, - language="de", - worksheet_files=[], - status="raw", - ) - - lu = create_learning_unit(lu_create) - return unit_to_frontend_dict(lu) - - -@router.post("/{unit_id}/attach-worksheets", response_model=Dict[str, Any]) -def api_attach_worksheets(unit_id: str, payload: AttachWorksheetsPayload): - """ - Fügt der Lerneinheit eine oder mehrere Arbeitsblätter hinzu. - """ - lu = get_learning_unit(unit_id) - if not lu: - raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") - - files_to_add = [f for f in payload.worksheet_files if f not in lu.worksheet_files] - if files_to_add: - new_list = lu.worksheet_files + files_to_add - update = LearningUnitUpdate(worksheet_files=new_list) - lu = update_learning_unit(unit_id, update) - if not lu: - raise HTTPException(status_code=500, detail="Lerneinheit konnte nicht aktualisiert werden.") - - return unit_to_frontend_dict(lu) - - -@router.post("/{unit_id}/remove-worksheet", response_model=Dict[str, Any]) -def api_remove_worksheet(unit_id: str, payload: RemoveWorksheetPayload): - """ - Entfernt genau ein Arbeitsblatt aus der Lerneinheit. - """ - lu = get_learning_unit(unit_id) - if not lu: - raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") - - if payload.worksheet_file not in lu.worksheet_files: - # Nichts zu tun, aber kein Fehler – einfach unverändert zurückgeben - return unit_to_frontend_dict(lu) - - new_list = [f for f in lu.worksheet_files if f != payload.worksheet_file] - update = LearningUnitUpdate(worksheet_files=new_list) - lu = update_learning_unit(unit_id, update) - if not lu: - raise HTTPException(status_code=500, detail="Lerneinheit konnte nicht aktualisiert werden.") - - return unit_to_frontend_dict(lu) - - -@router.delete("/{unit_id}") -def api_delete_learning_unit(unit_id: str): - """ - Lerneinheit komplett löschen (aktuell vom Frontend noch nicht verwendet). - """ - ok = delete_learning_unit(unit_id) - if not ok: - raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") - return {"status": "deleted", "id": unit_id} - - -# ---------- Generator-Endpunkte ---------- - -LERNEINHEITEN_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten") - - -def _save_analysis_and_get_path(unit_id: str, analysis_data: Dict[str, Any]) -> Path: - """Save analysis_data to disk and return the path.""" - os.makedirs(LERNEINHEITEN_DIR, exist_ok=True) - path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_analyse.json" - with open(path, "w", encoding="utf-8") as f: - json.dump(analysis_data, f, ensure_ascii=False, indent=2) - return path - - -@router.post("/{unit_id}/generate-qa") -def api_generate_qa(unit_id: str, payload: GenerateFromAnalysisPayload): - """Generate Q&A items with Leitner fields from analysis data.""" - lu = get_learning_unit(unit_id) - if not lu: - raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") - - analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data) - - try: - from ai_processing.qa_generator import generate_qa_from_analysis - qa_path = generate_qa_from_analysis(analysis_path, num_questions=payload.num_questions) - with open(qa_path, "r", encoding="utf-8") as f: - qa_data = json.load(f) - - # Update unit status - update_learning_unit(unit_id, LearningUnitUpdate(status="qa_generated")) - logger.info(f"Generated QA for unit {unit_id}: {len(qa_data.get('qa_items', []))} items") - return qa_data - except Exception as e: - logger.error(f"QA generation failed for {unit_id}: {e}") - raise HTTPException(status_code=500, detail=f"QA-Generierung fehlgeschlagen: {e}") - - -@router.post("/{unit_id}/generate-mc") -def api_generate_mc(unit_id: str, payload: GenerateFromAnalysisPayload): - """Generate multiple choice questions from analysis data.""" - lu = get_learning_unit(unit_id) - if not lu: - raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") - - analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data) - - try: - from ai_processing.mc_generator import generate_mc_from_analysis - mc_path = generate_mc_from_analysis(analysis_path, num_questions=payload.num_questions) - with open(mc_path, "r", encoding="utf-8") as f: - mc_data = json.load(f) - - update_learning_unit(unit_id, LearningUnitUpdate(status="mc_generated")) - logger.info(f"Generated MC for unit {unit_id}: {len(mc_data.get('questions', []))} questions") - return mc_data - except Exception as e: - logger.error(f"MC generation failed for {unit_id}: {e}") - raise HTTPException(status_code=500, detail=f"MC-Generierung fehlgeschlagen: {e}") - - -@router.post("/{unit_id}/generate-cloze") -def api_generate_cloze(unit_id: str, payload: GenerateFromAnalysisPayload): - """Generate cloze (fill-in-the-blank) items from analysis data.""" - lu = get_learning_unit(unit_id) - if not lu: - raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") - - analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data) - - try: - from ai_processing.cloze_generator import generate_cloze_from_analysis - cloze_path = generate_cloze_from_analysis(analysis_path) - with open(cloze_path, "r", encoding="utf-8") as f: - cloze_data = json.load(f) - - update_learning_unit(unit_id, LearningUnitUpdate(status="cloze_generated")) - logger.info(f"Generated Cloze for unit {unit_id}: {len(cloze_data.get('cloze_items', []))} items") - return cloze_data - except Exception as e: - logger.error(f"Cloze generation failed for {unit_id}: {e}") - raise HTTPException(status_code=500, detail=f"Cloze-Generierung fehlgeschlagen: {e}") - - -@router.get("/{unit_id}/qa") -def api_get_qa(unit_id: str): - """Get generated QA items for a unit.""" - qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json" - if not qa_path.exists(): - raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.") - with open(qa_path, "r", encoding="utf-8") as f: - return json.load(f) - - -@router.get("/{unit_id}/mc") -def api_get_mc(unit_id: str): - """Get generated MC questions for a unit.""" - mc_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_mc.json" - if not mc_path.exists(): - raise HTTPException(status_code=404, detail="Keine MC-Daten gefunden.") - with open(mc_path, "r", encoding="utf-8") as f: - return json.load(f) - - -@router.get("/{unit_id}/cloze") -def api_get_cloze(unit_id: str): - """Get generated cloze items for a unit.""" - cloze_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_cloze.json" - if not cloze_path.exists(): - raise HTTPException(status_code=404, detail="Keine Cloze-Daten gefunden.") - with open(cloze_path, "r", encoding="utf-8") as f: - return json.load(f) - - -@router.post("/{unit_id}/leitner/update") -def api_update_leitner(unit_id: str, item_id: str, correct: bool): - """Update Leitner progress for a QA item.""" - qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json" - if not qa_path.exists(): - raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.") - try: - from ai_processing.qa_generator import update_leitner_progress - result = update_leitner_progress(qa_path, item_id, correct) - return result - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{unit_id}/leitner/next") -def api_get_next_review(unit_id: str, limit: int = 5): - """Get next Leitner review items.""" - qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json" - if not qa_path.exists(): - raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.") - try: - from ai_processing.qa_generator import get_next_review_items - items = get_next_review_items(qa_path, limit=limit) - return {"items": items, "count": len(items)} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -class StoryGeneratePayload(BaseModel): - vocabulary: List[Dict[str, Any]] - language: str = "en" - grade_level: str = "5-8" - - -@router.post("/{unit_id}/generate-story") -def api_generate_story(unit_id: str, payload: StoryGeneratePayload): - """Generate a short story using vocabulary words.""" - lu = get_learning_unit(unit_id) - if not lu: - raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") - - try: - from story_generator import generate_story - result = generate_story( - vocabulary=payload.vocabulary, - language=payload.language, - grade_level=payload.grade_level, - ) - return result - except Exception as e: - logger.error(f"Story generation failed for {unit_id}: {e}") - raise HTTPException(status_code=500, detail=f"Story-Generierung fehlgeschlagen: {e}") - +# Backward-compat shim -- module moved to units/learning_api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.learning_api") diff --git a/backend-lehrer/letters/__init__.py b/backend-lehrer/letters/__init__.py new file mode 100644 index 0000000..3ffc485 --- /dev/null +++ b/backend-lehrer/letters/__init__.py @@ -0,0 +1 @@ +# letters — Elternbriefe and Zeugnisse (certificates). diff --git a/backend-lehrer/letters/api.py b/backend-lehrer/letters/api.py new file mode 100644 index 0000000..6226fd0 --- /dev/null +++ b/backend-lehrer/letters/api.py @@ -0,0 +1,346 @@ +""" +Letters API - Elternbrief-Verwaltung fuer BreakPilot. + +Bietet Endpoints fuer: +- Speichern und Laden von Elternbriefen +- PDF-Export von Briefen +- Versenden per Email +- GFK-Integration fuer Textverbesserung + +Split into: +- letters_models.py: Enums, Pydantic models, helper functions +- letters_api.py (this file): API endpoints and in-memory store +""" + +import logging +import os +import uuid +from datetime import datetime +from typing import Optional, Dict, Any + +from fastapi import APIRouter, HTTPException, Response, Query +import httpx + +# PDF service requires WeasyPrint with system libraries - make optional for CI +try: + from services.pdf_service import generate_letter_pdf, SchoolInfo + _pdf_available = True +except (ImportError, OSError): + generate_letter_pdf = None # type: ignore + SchoolInfo = None # type: ignore + _pdf_available = False + +from .models import ( + LetterType, + LetterTone, + LetterStatus, + LetterCreateRequest, + LetterUpdateRequest, + LetterResponse, + LetterListResponse, + ExportPDFRequest, + ImproveRequest, + ImproveResponse, + SendEmailRequest, + SendEmailResponse, + get_type_label as _get_type_label, + get_tone_label as _get_tone_label, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/letters", tags=["letters"]) + + +# ============================================================================= +# In-Memory Storage (Prototyp - spaeter durch DB ersetzen) +# ============================================================================= + +_letters_store: Dict[str, Dict[str, Any]] = {} + + +def _get_letter(letter_id: str) -> Dict[str, Any]: + """Holt Brief aus dem Store.""" + if letter_id not in _letters_store: + raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") + return _letters_store[letter_id] + + +def _save_letter(letter_data: Dict[str, Any]) -> str: + """Speichert Brief und gibt ID zurueck.""" + letter_id = letter_data.get("id") or str(uuid.uuid4()) + letter_data["id"] = letter_id + letter_data["updated_at"] = datetime.now() + if "created_at" not in letter_data: + letter_data["created_at"] = datetime.now() + _letters_store[letter_id] = letter_data + return letter_id + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@router.post("/", response_model=LetterResponse) +async def create_letter(request: LetterCreateRequest): + """Erstellt einen neuen Elternbrief.""" + logger.info(f"Creating new letter for student: {request.student_name}") + + letter_data = { + "recipient_name": request.recipient_name, + "recipient_address": request.recipient_address, + "student_name": request.student_name, + "student_class": request.student_class, + "subject": request.subject, + "content": request.content, + "letter_type": request.letter_type, + "tone": request.tone, + "teacher_name": request.teacher_name, + "teacher_title": request.teacher_title, + "school_info": request.school_info.model_dump() if request.school_info else None, + "legal_references": [ref.model_dump() for ref in request.legal_references] if request.legal_references else None, + "gfk_principles_applied": request.gfk_principles_applied, + "gfk_score": None, + "status": LetterStatus.DRAFT, + "pdf_path": None, + "dsms_cid": None, + "sent_at": None, + } + + letter_id = _save_letter(letter_data) + letter_data["id"] = letter_id + logger.info(f"Letter created with ID: {letter_id}") + return LetterResponse(**letter_data) + + +# NOTE: Static routes must come BEFORE dynamic routes like /{letter_id} +@router.get("/types") +async def get_letter_types(): + """Gibt alle verfuegbaren Brieftypen zurueck.""" + return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in LetterType]} + + +@router.get("/tones") +async def get_letter_tones(): + """Gibt alle verfuegbaren Tonalitaeten zurueck.""" + return {"tones": [{"value": t.value, "label": _get_tone_label(t)} for t in LetterTone]} + + +@router.get("/{letter_id}", response_model=LetterResponse) +async def get_letter(letter_id: str): + """Laedt einen gespeicherten Brief.""" + logger.info(f"Getting letter: {letter_id}") + letter_data = _get_letter(letter_id) + return LetterResponse(**letter_data) + + +@router.get("/", response_model=LetterListResponse) +async def list_letters( + student_id: Optional[str] = Query(None), + class_name: Optional[str] = Query(None), + letter_type: Optional[LetterType] = Query(None), + status: Optional[LetterStatus] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100) +): + """Listet alle gespeicherten Briefe mit optionalen Filtern.""" + logger.info("Listing letters with filters") + + filtered_letters = list(_letters_store.values()) + if class_name: + filtered_letters = [l for l in filtered_letters if l.get("student_class") == class_name] + if letter_type: + filtered_letters = [l for l in filtered_letters if l.get("letter_type") == letter_type] + if status: + filtered_letters = [l for l in filtered_letters if l.get("status") == status] + + filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) + total = len(filtered_letters) + start = (page - 1) * page_size + paginated_letters = filtered_letters[start:start + page_size] + + return LetterListResponse( + letters=[LetterResponse(**l) for l in paginated_letters], + total=total, page=page, page_size=page_size + ) + + +@router.put("/{letter_id}", response_model=LetterResponse) +async def update_letter(letter_id: str, request: LetterUpdateRequest): + """Aktualisiert einen bestehenden Brief.""" + logger.info(f"Updating letter: {letter_id}") + letter_data = _get_letter(letter_id) + + update_data = request.model_dump(exclude_unset=True) + for key, value in update_data.items(): + if value is not None: + if key == "school_info" and value: + letter_data[key] = value if isinstance(value, dict) else value.model_dump() + elif key == "legal_references" and value: + letter_data[key] = [ref if isinstance(ref, dict) else ref.model_dump() for ref in value] + else: + letter_data[key] = value + + _save_letter(letter_data) + return LetterResponse(**letter_data) + + +@router.delete("/{letter_id}") +async def delete_letter(letter_id: str): + """Loescht einen Brief.""" + logger.info(f"Deleting letter: {letter_id}") + if letter_id not in _letters_store: + raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") + del _letters_store[letter_id] + return {"message": f"Brief {letter_id} wurde geloescht"} + + +@router.post("/export-pdf") +async def export_letter_pdf(request: ExportPDFRequest): + """Exportiert einen Brief als PDF.""" + logger.info("Exporting letter as PDF") + + if request.letter_id: + letter_data = _get_letter(request.letter_id) + elif request.letter_data: + letter_data = request.letter_data.model_dump() + else: + raise HTTPException(status_code=400, detail="Entweder letter_id oder letter_data muss angegeben werden") + + if "date" not in letter_data: + letter_data["date"] = datetime.now().strftime("%d.%m.%Y") + + try: + pdf_bytes = generate_letter_pdf(letter_data) + except Exception as e: + logger.error(f"Error generating PDF: {e}") + raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}") + + student_name = letter_data.get("student_name", "Brief").replace(" ", "_") + date_str = datetime.now().strftime("%Y%m%d") + filename = f"Elternbrief_{student_name}_{date_str}.pdf" + + return Response( + content=pdf_bytes, media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes))} + ) + + +@router.post("/{letter_id}/export-pdf") +async def export_saved_letter_pdf(letter_id: str): + """Exportiert einen gespeicherten Brief als PDF (Kurzform).""" + return await export_letter_pdf(ExportPDFRequest(letter_id=letter_id)) + + +@router.post("/improve", response_model=ImproveResponse) +async def improve_letter_content(request: ImproveRequest): + """Verbessert den Briefinhalt nach GFK-Prinzipien.""" + logger.info("Improving letter content with GFK principles") + + comm_service_url = os.getenv("COMMUNICATION_SERVICE_URL", "http://localhost:8000/v1/communication") + + try: + async with httpx.AsyncClient() as client: + validate_response = await client.post( + f"{comm_service_url}/validate", + json={"text": request.content}, timeout=30.0 + ) + + if validate_response.status_code != 200: + logger.warning(f"Validation service returned {validate_response.status_code}") + return ImproveResponse( + improved_content=request.content, + changes=["Verbesserungsservice nicht verfuegbar"], + gfk_score=0.5, gfk_principles_applied=[] + ) + + validation_data = validate_response.json() + + if validation_data.get("is_valid", False) and validation_data.get("gfk_score", 0) > 0.8: + return ImproveResponse( + improved_content=request.content, + changes=["Text entspricht bereits GFK-Standards"], + gfk_score=validation_data.get("gfk_score", 0.8), + gfk_principles_applied=validation_data.get("positive_elements", []) + ) + + return ImproveResponse( + improved_content=request.content, + changes=validation_data.get("suggestions", []), + gfk_score=validation_data.get("gfk_score", 0.5), + gfk_principles_applied=validation_data.get("positive_elements", []) + ) + + except httpx.TimeoutException: + logger.error("Timeout while calling communication service") + return ImproveResponse( + improved_content=request.content, + changes=["Zeitueberschreitung beim Verbesserungsservice"], + gfk_score=0.5, gfk_principles_applied=[] + ) + except Exception as e: + logger.error(f"Error improving content: {e}") + return ImproveResponse( + improved_content=request.content, + changes=[f"Fehler: {str(e)}"], + gfk_score=0.5, gfk_principles_applied=[] + ) + + +@router.post("/{letter_id}/send", response_model=SendEmailResponse) +async def send_letter_email(letter_id: str, request: SendEmailRequest): + """Versendet einen Brief per Email.""" + logger.info(f"Sending letter {letter_id} to {request.recipient_email}") + letter_data = _get_letter(letter_id) + + try: + pdf_attachment = None + if request.include_pdf: + letter_data["date"] = datetime.now().strftime("%d.%m.%Y") + pdf_bytes = generate_letter_pdf(letter_data) + pdf_attachment = { + "filename": f"Elternbrief_{letter_data.get('student_name', 'Brief').replace(' ', '_')}.pdf", + "content": pdf_bytes.hex(), + "content_type": "application/pdf" + } + + async with httpx.AsyncClient() as client: + logger.info(f"Would send email: {letter_data.get('subject')} to {request.recipient_email}") + letter_data["status"] = LetterStatus.SENT + letter_data["sent_at"] = datetime.now() + _save_letter(letter_data) + + return SendEmailResponse( + success=True, + message=f"Brief wurde an {request.recipient_email} gesendet", + sent_at=datetime.now() + ) + + except Exception as e: + logger.error(f"Error sending email: {e}") + return SendEmailResponse(success=False, message=f"Fehler beim Versenden: {str(e)}", sent_at=None) + + +@router.get("/student/{student_id}", response_model=LetterListResponse) +async def get_letters_for_student( + student_id: str, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100) +): + """Laedt alle Briefe fuer einen bestimmten Schueler.""" + logger.info(f"Getting letters for student: {student_id}") + + filtered_letters = [ + l for l in _letters_store.values() + if student_id.lower() in l.get("student_name", "").lower() + ] + + filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) + total = len(filtered_letters) + start = (page - 1) * page_size + paginated_letters = filtered_letters[start:start + page_size] + + return LetterListResponse( + letters=[LetterResponse(**l) for l in paginated_letters], + total=total, page=page, page_size=page_size + ) diff --git a/backend-lehrer/letters/certificates_api.py b/backend-lehrer/letters/certificates_api.py new file mode 100644 index 0000000..6162f87 --- /dev/null +++ b/backend-lehrer/letters/certificates_api.py @@ -0,0 +1,340 @@ +""" +Certificates API - Zeugnisverwaltung fuer BreakPilot. + +Split into: +- certificates_models.py: Enums, Pydantic models, helper functions +- certificates_api.py (this file): API endpoints and in-memory store +""" + +import logging +import uuid +from datetime import datetime +from typing import Optional, Dict, List, Any + +from fastapi import APIRouter, HTTPException, Response, Query + +# PDF service requires WeasyPrint with system libraries - make optional for CI +try: + from services.pdf_service import generate_certificate_pdf, SchoolInfo + _pdf_available = True +except (ImportError, OSError): + generate_certificate_pdf = None # type: ignore + SchoolInfo = None # type: ignore + _pdf_available = False + +from .certificates_models import ( + CertificateType, + CertificateStatus, + BehaviorGrade, + CertificateCreateRequest, + CertificateUpdateRequest, + CertificateResponse, + CertificateListResponse, + GradeStatistics, + get_type_label as _get_type_label, + calculate_average as _calculate_average, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/certificates", tags=["certificates"]) + + +# ============================================================================= +# In-Memory Storage (Prototyp - spaeter durch DB ersetzen) +# ============================================================================= + +_certificates_store: Dict[str, Dict[str, Any]] = {} + + +def _get_certificate(cert_id: str) -> Dict[str, Any]: + """Holt Zeugnis aus dem Store.""" + if cert_id not in _certificates_store: + raise HTTPException(status_code=404, detail=f"Zeugnis mit ID {cert_id} nicht gefunden") + return _certificates_store[cert_id] + + +def _save_certificate(cert_data: Dict[str, Any]) -> str: + """Speichert Zeugnis und gibt ID zurueck.""" + cert_id = cert_data.get("id") or str(uuid.uuid4()) + cert_data["id"] = cert_id + cert_data["updated_at"] = datetime.now() + if "created_at" not in cert_data: + cert_data["created_at"] = datetime.now() + _certificates_store[cert_id] = cert_data + return cert_id + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@router.post("/", response_model=CertificateResponse) +async def create_certificate(request: CertificateCreateRequest): + """Erstellt ein neues Zeugnis.""" + logger.info(f"Creating new certificate for student: {request.student_name}") + + subjects_list = [s.model_dump() for s in request.subjects] + + cert_data = { + "student_id": request.student_id, + "student_name": request.student_name, + "student_birthdate": request.student_birthdate, + "student_class": request.student_class, + "school_year": request.school_year, + "certificate_type": request.certificate_type, + "subjects": subjects_list, + "attendance": request.attendance.model_dump(), + "remarks": request.remarks, + "class_teacher": request.class_teacher, + "principal": request.principal, + "school_info": request.school_info.model_dump() if request.school_info else None, + "issue_date": request.issue_date or datetime.now().strftime("%d.%m.%Y"), + "social_behavior": request.social_behavior, + "work_behavior": request.work_behavior, + "status": CertificateStatus.DRAFT, + "average_grade": _calculate_average(subjects_list), + "pdf_path": None, + "dsms_cid": None, + } + + cert_id = _save_certificate(cert_data) + cert_data["id"] = cert_id + logger.info(f"Certificate created with ID: {cert_id}") + return CertificateResponse(**cert_data) + + +# IMPORTANT: Static routes must be defined BEFORE dynamic /{cert_id} route +@router.get("/types") +async def get_certificate_types(): + """Gibt alle verfuegbaren Zeugnistypen zurueck.""" + return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in CertificateType]} + + +@router.get("/behavior-grades") +async def get_behavior_grades(): + """Gibt alle verfuegbaren Verhaltensnoten zurueck.""" + labels = { + BehaviorGrade.A: "A - Sehr gut", BehaviorGrade.B: "B - Gut", + BehaviorGrade.C: "C - Befriedigend", BehaviorGrade.D: "D - Verbesserungswuerdig" + } + return {"grades": [{"value": g.value, "label": labels[g]} for g in BehaviorGrade]} + + +@router.get("/{cert_id}", response_model=CertificateResponse) +async def get_certificate(cert_id: str): + """Laedt ein gespeichertes Zeugnis.""" + logger.info(f"Getting certificate: {cert_id}") + return CertificateResponse(**_get_certificate(cert_id)) + + +@router.get("/", response_model=CertificateListResponse) +async def list_certificates( + student_id: Optional[str] = Query(None), + class_name: Optional[str] = Query(None), + school_year: Optional[str] = Query(None), + certificate_type: Optional[CertificateType] = Query(None), + status: Optional[CertificateStatus] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100) +): + """Listet alle gespeicherten Zeugnisse mit optionalen Filtern.""" + logger.info("Listing certificates with filters") + + filtered_certs = list(_certificates_store.values()) + if student_id: + filtered_certs = [c for c in filtered_certs if c.get("student_id") == student_id] + if class_name: + filtered_certs = [c for c in filtered_certs if c.get("student_class") == class_name] + if school_year: + filtered_certs = [c for c in filtered_certs if c.get("school_year") == school_year] + if certificate_type: + filtered_certs = [c for c in filtered_certs if c.get("certificate_type") == certificate_type] + if status: + filtered_certs = [c for c in filtered_certs if c.get("status") == status] + + filtered_certs.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) + total = len(filtered_certs) + start = (page - 1) * page_size + paginated_certs = filtered_certs[start:start + page_size] + + return CertificateListResponse( + certificates=[CertificateResponse(**c) for c in paginated_certs], + total=total, page=page, page_size=page_size + ) + + +@router.put("/{cert_id}", response_model=CertificateResponse) +async def update_certificate(cert_id: str, request: CertificateUpdateRequest): + """Aktualisiert ein bestehendes Zeugnis.""" + logger.info(f"Updating certificate: {cert_id}") + cert_data = _get_certificate(cert_id) + + if cert_data.get("status") in [CertificateStatus.ISSUED, CertificateStatus.ARCHIVED]: + raise HTTPException(status_code=400, detail="Zeugnis wurde bereits ausgestellt und kann nicht mehr bearbeitet werden") + + update_data = request.model_dump(exclude_unset=True) + for key, value in update_data.items(): + if value is not None: + if key == "subjects": + cert_data[key] = [s if isinstance(s, dict) else s.model_dump() for s in value] + cert_data["average_grade"] = _calculate_average(cert_data["subjects"]) + elif key == "attendance": + cert_data[key] = value if isinstance(value, dict) else value.model_dump() + else: + cert_data[key] = value + + _save_certificate(cert_data) + return CertificateResponse(**cert_data) + + +@router.delete("/{cert_id}") +async def delete_certificate(cert_id: str): + """Loescht ein Zeugnis. Nur Entwuerfe koennen geloescht werden.""" + logger.info(f"Deleting certificate: {cert_id}") + cert_data = _get_certificate(cert_id) + if cert_data.get("status") != CertificateStatus.DRAFT: + raise HTTPException(status_code=400, detail="Nur Zeugnis-Entwuerfe koennen geloescht werden") + del _certificates_store[cert_id] + return {"message": f"Zeugnis {cert_id} wurde geloescht"} + + +@router.post("/{cert_id}/export-pdf") +async def export_certificate_pdf(cert_id: str): + """Exportiert ein Zeugnis als PDF.""" + logger.info(f"Exporting certificate {cert_id} as PDF") + cert_data = _get_certificate(cert_id) + + try: + pdf_bytes = generate_certificate_pdf(cert_data) + except Exception as e: + logger.error(f"Error generating PDF: {e}") + raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}") + + student_name = cert_data.get("student_name", "Zeugnis").replace(" ", "_") + school_year = cert_data.get("school_year", "").replace("/", "-") + cert_type = cert_data.get("certificate_type", "zeugnis") + filename = f"Zeugnis_{student_name}_{cert_type}_{school_year}.pdf" + + from urllib.parse import quote + filename_ascii = filename.encode('ascii', 'replace').decode('ascii') + filename_encoded = quote(filename, safe='') + + return Response( + content=pdf_bytes, media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}", + "Content-Length": str(len(pdf_bytes)) + } + ) + + +@router.post("/{cert_id}/submit-review") +async def submit_for_review(cert_id: str): + """Reicht Zeugnis zur Pruefung ein.""" + logger.info(f"Submitting certificate {cert_id} for review") + cert_data = _get_certificate(cert_id) + if cert_data.get("status") != CertificateStatus.DRAFT: + raise HTTPException(status_code=400, detail="Nur Entwuerfe koennen zur Pruefung eingereicht werden") + if not cert_data.get("subjects"): + raise HTTPException(status_code=400, detail="Keine Fachnoten eingetragen") + cert_data["status"] = CertificateStatus.REVIEW + _save_certificate(cert_data) + return {"message": "Zeugnis wurde zur Pruefung eingereicht", "status": CertificateStatus.REVIEW} + + +@router.post("/{cert_id}/approve") +async def approve_certificate(cert_id: str): + """Genehmigt ein Zeugnis.""" + logger.info(f"Approving certificate {cert_id}") + cert_data = _get_certificate(cert_id) + if cert_data.get("status") != CertificateStatus.REVIEW: + raise HTTPException(status_code=400, detail="Nur Zeugnisse in Pruefung koennen genehmigt werden") + cert_data["status"] = CertificateStatus.APPROVED + _save_certificate(cert_data) + return {"message": "Zeugnis wurde genehmigt", "status": CertificateStatus.APPROVED} + + +@router.post("/{cert_id}/issue") +async def issue_certificate(cert_id: str): + """Stellt ein Zeugnis offiziell aus.""" + logger.info(f"Issuing certificate {cert_id}") + cert_data = _get_certificate(cert_id) + if cert_data.get("status") != CertificateStatus.APPROVED: + raise HTTPException(status_code=400, detail="Nur genehmigte Zeugnisse koennen ausgestellt werden") + cert_data["status"] = CertificateStatus.ISSUED + cert_data["issue_date"] = datetime.now().strftime("%d.%m.%Y") + _save_certificate(cert_data) + return {"message": "Zeugnis wurde ausgestellt", "status": CertificateStatus.ISSUED, "issue_date": cert_data["issue_date"]} + + +@router.get("/student/{student_id}", response_model=CertificateListResponse) +async def get_certificates_for_student( + student_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100) +): + """Laedt alle Zeugnisse fuer einen bestimmten Schueler.""" + logger.info(f"Getting certificates for student: {student_id}") + filtered_certs = [c for c in _certificates_store.values() if c.get("student_id") == student_id] + filtered_certs.sort(key=lambda x: (x.get("school_year", ""), x.get("certificate_type", "")), reverse=True) + total = len(filtered_certs) + start = (page - 1) * page_size + paginated_certs = filtered_certs[start:start + page_size] + return CertificateListResponse( + certificates=[CertificateResponse(**c) for c in paginated_certs], + total=total, page=page, page_size=page_size + ) + + +@router.get("/class/{class_name}/statistics", response_model=GradeStatistics) +async def get_class_statistics( + class_name: str, + school_year: str = Query(..., description="Schuljahr"), + certificate_type: CertificateType = Query(CertificateType.HALBJAHR) +): + """Berechnet Notenstatistiken fuer eine Klasse.""" + logger.info(f"Calculating statistics for class {class_name}") + + class_certs = [ + c for c in _certificates_store.values() + if c.get("student_class") == class_name + and c.get("school_year") == school_year + and c.get("certificate_type") == certificate_type + ] + + if not class_certs: + raise HTTPException(status_code=404, detail=f"Keine Zeugnisse fuer Klasse {class_name} im Schuljahr {school_year} gefunden") + + all_grades: List[float] = [] + subject_grades: Dict[str, List[float]] = {} + grade_counts = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0} + + for cert in class_certs: + avg = cert.get("average_grade") + if avg: + all_grades.append(avg) + rounded = str(round(avg)) + if rounded in grade_counts: + grade_counts[rounded] += 1 + + for subject in cert.get("subjects", []): + name = subject.get("name") + grade_str = subject.get("grade") + try: + grade = float(grade_str) + if name not in subject_grades: + subject_grades[name] = [] + subject_grades[name].append(grade) + except (ValueError, TypeError): + pass + + subject_averages = { + name: round(sum(grades) / len(grades), 2) + for name, grades in subject_grades.items() if grades + } + + return GradeStatistics( + class_name=class_name, school_year=school_year, + certificate_type=certificate_type, student_count=len(class_certs), + average_grade=round(sum(all_grades) / len(all_grades), 2) if all_grades else 0.0, + grade_distribution=grade_counts, subject_averages=subject_averages + ) diff --git a/backend-lehrer/letters/certificates_models.py b/backend-lehrer/letters/certificates_models.py new file mode 100644 index 0000000..08a8c41 --- /dev/null +++ b/backend-lehrer/letters/certificates_models.py @@ -0,0 +1,184 @@ +""" +Certificates Models - Pydantic models and enums for Zeugnisverwaltung. +""" +from datetime import datetime +from typing import Optional, List, Dict +from enum import Enum + +from pydantic import BaseModel, Field + + +# ============================================================================= +# Enums +# ============================================================================= + +class CertificateType(str, Enum): + """Typen von Zeugnissen.""" + HALBJAHR = "halbjahr" + JAHRES = "jahres" + ABSCHLUSS = "abschluss" + ABGANG = "abgang" + UEBERGANG = "uebergang" + + +class CertificateStatus(str, Enum): + """Status eines Zeugnisses.""" + DRAFT = "draft" + REVIEW = "review" + APPROVED = "approved" + ISSUED = "issued" + ARCHIVED = "archived" + + +class GradeType(str, Enum): + """Notentyp.""" + NUMERIC = "numeric" + POINTS = "points" + TEXT = "text" + + +class BehaviorGrade(str, Enum): + """Verhaltens-/Arbeitsnoten.""" + A = "A" + B = "B" + C = "C" + D = "D" + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + +class SchoolInfoModel(BaseModel): + """Schulinformationen fuer Zeugnis.""" + name: str + address: str + phone: str + email: str + website: Optional[str] = None + principal: Optional[str] = None + logo_path: Optional[str] = None + + +class SubjectGrade(BaseModel): + """Note fuer ein Fach.""" + name: str = Field(..., description="Fachname") + grade: str = Field(..., description="Note (1-6 oder A-D)") + points: Optional[int] = Field(None, description="Punkte (Oberstufe, 0-15)") + note: Optional[str] = Field(None, description="Bemerkung zum Fach") + + +class AttendanceInfo(BaseModel): + """Anwesenheitsinformationen.""" + days_absent: int = Field(0, description="Fehlende Tage gesamt") + days_excused: int = Field(0, description="Entschuldigte Tage") + days_unexcused: int = Field(0, description="Unentschuldigte Tage") + hours_absent: Optional[int] = Field(None, description="Fehlstunden gesamt") + + +class CertificateCreateRequest(BaseModel): + """Request zum Erstellen eines neuen Zeugnisses.""" + student_id: str = Field(..., description="ID des Schuelers") + student_name: str = Field(..., description="Name des Schuelers") + student_birthdate: str = Field(..., description="Geburtsdatum") + student_class: str = Field(..., description="Klasse") + school_year: str = Field(..., description="Schuljahr (z.B. '2024/2025')") + certificate_type: CertificateType = Field(..., description="Art des Zeugnisses") + subjects: List[SubjectGrade] = Field(..., description="Fachnoten") + attendance: AttendanceInfo = Field(default_factory=AttendanceInfo) + remarks: Optional[str] = Field(None, description="Bemerkungen") + class_teacher: str = Field(..., description="Klassenlehrer/in") + principal: str = Field(..., description="Schulleiter/in") + school_info: Optional[SchoolInfoModel] = Field(None) + issue_date: Optional[str] = Field(None, description="Ausstellungsdatum") + social_behavior: Optional[BehaviorGrade] = Field(None) + work_behavior: Optional[BehaviorGrade] = Field(None) + + +class CertificateUpdateRequest(BaseModel): + """Request zum Aktualisieren eines Zeugnisses.""" + subjects: Optional[List[SubjectGrade]] = None + attendance: Optional[AttendanceInfo] = None + remarks: Optional[str] = None + class_teacher: Optional[str] = None + principal: Optional[str] = None + social_behavior: Optional[BehaviorGrade] = None + work_behavior: Optional[BehaviorGrade] = None + status: Optional[CertificateStatus] = None + + +class CertificateResponse(BaseModel): + """Response mit Zeugnisdaten.""" + id: str + student_id: str + student_name: str + student_birthdate: str + student_class: str + school_year: str + certificate_type: CertificateType + subjects: List[SubjectGrade] + attendance: AttendanceInfo + remarks: Optional[str] + class_teacher: str + principal: str + school_info: Optional[SchoolInfoModel] + issue_date: Optional[str] + social_behavior: Optional[BehaviorGrade] + work_behavior: Optional[BehaviorGrade] + status: CertificateStatus + average_grade: Optional[float] + pdf_path: Optional[str] + dsms_cid: Optional[str] + created_at: datetime + updated_at: datetime + + +class CertificateListResponse(BaseModel): + """Response mit Liste von Zeugnissen.""" + certificates: List[CertificateResponse] + total: int + page: int + page_size: int + + +class GradeStatistics(BaseModel): + """Notenstatistiken fuer eine Klasse.""" + class_name: str + school_year: str + certificate_type: CertificateType + student_count: int + average_grade: float + grade_distribution: Dict[str, int] + subject_averages: Dict[str, float] + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_type_label(cert_type: CertificateType) -> str: + """Gibt menschenlesbare Labels fuer Zeugnistypen zurueck.""" + labels = { + CertificateType.HALBJAHR: "Halbjahreszeugnis", + CertificateType.JAHRES: "Jahreszeugnis", + CertificateType.ABSCHLUSS: "Abschlusszeugnis", + CertificateType.ABGANG: "Abgangszeugnis", + CertificateType.UEBERGANG: "Uebergangszeugnis", + } + return labels.get(cert_type, cert_type.value) + + +def calculate_average(subjects: List[Dict]) -> Optional[float]: + """Berechnet Notendurchschnitt.""" + numeric_grades = [] + for subject in subjects: + grade = subject.get("grade", "") + try: + numeric = float(grade) + if 1 <= numeric <= 6: + numeric_grades.append(numeric) + except (ValueError, TypeError): + pass + if numeric_grades: + return round(sum(numeric_grades) / len(numeric_grades), 2) + return None diff --git a/backend-lehrer/letters/models.py b/backend-lehrer/letters/models.py new file mode 100644 index 0000000..f45565e --- /dev/null +++ b/backend-lehrer/letters/models.py @@ -0,0 +1,195 @@ +""" +Letters Models - Pydantic models and enums for Elternbrief-Verwaltung. +""" +from datetime import datetime +from typing import Optional, List +from enum import Enum + +from pydantic import BaseModel, Field + + +# ============================================================================= +# Enums +# ============================================================================= + +class LetterType(str, Enum): + """Typen von Elternbriefen.""" + GENERAL = "general" + HALBJAHR = "halbjahr" + FEHLZEITEN = "fehlzeiten" + ELTERNABEND = "elternabend" + LOB = "lob" + CUSTOM = "custom" + + +class LetterTone(str, Enum): + """Tonalitaet der Briefe.""" + FORMAL = "formal" + PROFESSIONAL = "professional" + WARM = "warm" + CONCERNED = "concerned" + APPRECIATIVE = "appreciative" + + +class LetterStatus(str, Enum): + """Status eines Briefes.""" + DRAFT = "draft" + SENT = "sent" + ARCHIVED = "archived" + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + +class SchoolInfoModel(BaseModel): + """Schulinformationen fuer Briefkopf.""" + name: str + address: str + phone: str + email: str + website: Optional[str] = None + principal: Optional[str] = None + logo_path: Optional[str] = None + + +class LegalReferenceModel(BaseModel): + """Rechtliche Referenz.""" + law: str + paragraph: str + title: str + summary: Optional[str] = None + relevance: Optional[str] = None + + +class LetterCreateRequest(BaseModel): + """Request zum Erstellen eines neuen Briefes.""" + recipient_name: str = Field(..., description="Name des Empfaengers") + recipient_address: str = Field(..., description="Adresse des Empfaengers") + student_name: str = Field(..., description="Name des Schuelers") + student_class: str = Field(..., description="Klasse des Schuelers") + subject: str = Field(..., description="Betreff des Briefes") + content: str = Field(..., description="Inhalt des Briefes") + letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes") + tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalitaet des Briefes") + teacher_name: str = Field(..., description="Name des Lehrers") + teacher_title: Optional[str] = Field(None, description="Titel des Lehrers") + school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen") + legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen") + gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien") + + +class LetterUpdateRequest(BaseModel): + """Request zum Aktualisieren eines Briefes.""" + recipient_name: Optional[str] = None + recipient_address: Optional[str] = None + student_name: Optional[str] = None + student_class: Optional[str] = None + subject: Optional[str] = None + content: Optional[str] = None + letter_type: Optional[LetterType] = None + tone: Optional[LetterTone] = None + teacher_name: Optional[str] = None + teacher_title: Optional[str] = None + school_info: Optional[SchoolInfoModel] = None + legal_references: Optional[List[LegalReferenceModel]] = None + gfk_principles_applied: Optional[List[str]] = None + status: Optional[LetterStatus] = None + + +class LetterResponse(BaseModel): + """Response mit Briefdaten.""" + id: str + recipient_name: str + recipient_address: str + student_name: str + student_class: str + subject: str + content: str + letter_type: LetterType + tone: LetterTone + teacher_name: str + teacher_title: Optional[str] + school_info: Optional[SchoolInfoModel] + legal_references: Optional[List[LegalReferenceModel]] + gfk_principles_applied: Optional[List[str]] + gfk_score: Optional[float] + status: LetterStatus + pdf_path: Optional[str] + dsms_cid: Optional[str] + sent_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + +class LetterListResponse(BaseModel): + """Response mit Liste von Briefen.""" + letters: List[LetterResponse] + total: int + page: int + page_size: int + + +class ExportPDFRequest(BaseModel): + """Request zum PDF-Export.""" + letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes") + letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten") + + +class ImproveRequest(BaseModel): + """Request zur GFK-Verbesserung.""" + content: str = Field(..., description="Text zur Verbesserung") + communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation") + tone: Optional[str] = Field("professional", description="Gewuenschte Tonalitaet") + + +class ImproveResponse(BaseModel): + """Response mit verbessertem Text.""" + improved_content: str + changes: List[str] + gfk_score: float + gfk_principles_applied: List[str] + + +class SendEmailRequest(BaseModel): + """Request zum Email-Versand.""" + letter_id: str + recipient_email: str + cc_emails: Optional[List[str]] = None + include_pdf: bool = True + + +class SendEmailResponse(BaseModel): + """Response nach Email-Versand.""" + success: bool + message: str + sent_at: Optional[datetime] + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_type_label(letter_type: LetterType) -> str: + """Gibt menschenlesbare Labels fuer Brieftypen zurueck.""" + labels = { + LetterType.GENERAL: "Allgemeine Information", + LetterType.HALBJAHR: "Halbjahresinformation", + LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung", + LetterType.ELTERNABEND: "Einladung Elternabend", + LetterType.LOB: "Positives Feedback", + LetterType.CUSTOM: "Benutzerdefiniert", + } + return labels.get(letter_type, letter_type.value) + + +def get_tone_label(tone: LetterTone) -> str: + """Gibt menschenlesbare Labels fuer Tonalitaeten zurueck.""" + labels = { + LetterTone.FORMAL: "Sehr foermlich", + LetterTone.PROFESSIONAL: "Professionell-freundlich", + LetterTone.WARM: "Warmherzig", + LetterTone.CONCERNED: "Besorgt", + LetterTone.APPRECIATIVE: "Wertschaetzend", + } + return labels.get(tone, tone.value) diff --git a/backend-lehrer/letters_api.py b/backend-lehrer/letters_api.py index 8f8a5e4..8e654a4 100644 --- a/backend-lehrer/letters_api.py +++ b/backend-lehrer/letters_api.py @@ -1,346 +1,4 @@ -""" -Letters API - Elternbrief-Verwaltung fuer BreakPilot. - -Bietet Endpoints fuer: -- Speichern und Laden von Elternbriefen -- PDF-Export von Briefen -- Versenden per Email -- GFK-Integration fuer Textverbesserung - -Split into: -- letters_models.py: Enums, Pydantic models, helper functions -- letters_api.py (this file): API endpoints and in-memory store -""" - -import logging -import os -import uuid -from datetime import datetime -from typing import Optional, Dict, Any - -from fastapi import APIRouter, HTTPException, Response, Query -import httpx - -# PDF service requires WeasyPrint with system libraries - make optional for CI -try: - from services.pdf_service import generate_letter_pdf, SchoolInfo - _pdf_available = True -except (ImportError, OSError): - generate_letter_pdf = None # type: ignore - SchoolInfo = None # type: ignore - _pdf_available = False - -from letters_models import ( - LetterType, - LetterTone, - LetterStatus, - LetterCreateRequest, - LetterUpdateRequest, - LetterResponse, - LetterListResponse, - ExportPDFRequest, - ImproveRequest, - ImproveResponse, - SendEmailRequest, - SendEmailResponse, - get_type_label as _get_type_label, - get_tone_label as _get_tone_label, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/letters", tags=["letters"]) - - -# ============================================================================= -# In-Memory Storage (Prototyp - spaeter durch DB ersetzen) -# ============================================================================= - -_letters_store: Dict[str, Dict[str, Any]] = {} - - -def _get_letter(letter_id: str) -> Dict[str, Any]: - """Holt Brief aus dem Store.""" - if letter_id not in _letters_store: - raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") - return _letters_store[letter_id] - - -def _save_letter(letter_data: Dict[str, Any]) -> str: - """Speichert Brief und gibt ID zurueck.""" - letter_id = letter_data.get("id") or str(uuid.uuid4()) - letter_data["id"] = letter_id - letter_data["updated_at"] = datetime.now() - if "created_at" not in letter_data: - letter_data["created_at"] = datetime.now() - _letters_store[letter_id] = letter_data - return letter_id - - -# ============================================================================= -# API Endpoints -# ============================================================================= - -@router.post("/", response_model=LetterResponse) -async def create_letter(request: LetterCreateRequest): - """Erstellt einen neuen Elternbrief.""" - logger.info(f"Creating new letter for student: {request.student_name}") - - letter_data = { - "recipient_name": request.recipient_name, - "recipient_address": request.recipient_address, - "student_name": request.student_name, - "student_class": request.student_class, - "subject": request.subject, - "content": request.content, - "letter_type": request.letter_type, - "tone": request.tone, - "teacher_name": request.teacher_name, - "teacher_title": request.teacher_title, - "school_info": request.school_info.model_dump() if request.school_info else None, - "legal_references": [ref.model_dump() for ref in request.legal_references] if request.legal_references else None, - "gfk_principles_applied": request.gfk_principles_applied, - "gfk_score": None, - "status": LetterStatus.DRAFT, - "pdf_path": None, - "dsms_cid": None, - "sent_at": None, - } - - letter_id = _save_letter(letter_data) - letter_data["id"] = letter_id - logger.info(f"Letter created with ID: {letter_id}") - return LetterResponse(**letter_data) - - -# NOTE: Static routes must come BEFORE dynamic routes like /{letter_id} -@router.get("/types") -async def get_letter_types(): - """Gibt alle verfuegbaren Brieftypen zurueck.""" - return {"types": [{"value": t.value, "label": _get_type_label(t)} for t in LetterType]} - - -@router.get("/tones") -async def get_letter_tones(): - """Gibt alle verfuegbaren Tonalitaeten zurueck.""" - return {"tones": [{"value": t.value, "label": _get_tone_label(t)} for t in LetterTone]} - - -@router.get("/{letter_id}", response_model=LetterResponse) -async def get_letter(letter_id: str): - """Laedt einen gespeicherten Brief.""" - logger.info(f"Getting letter: {letter_id}") - letter_data = _get_letter(letter_id) - return LetterResponse(**letter_data) - - -@router.get("/", response_model=LetterListResponse) -async def list_letters( - student_id: Optional[str] = Query(None), - class_name: Optional[str] = Query(None), - letter_type: Optional[LetterType] = Query(None), - status: Optional[LetterStatus] = Query(None), - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100) -): - """Listet alle gespeicherten Briefe mit optionalen Filtern.""" - logger.info("Listing letters with filters") - - filtered_letters = list(_letters_store.values()) - if class_name: - filtered_letters = [l for l in filtered_letters if l.get("student_class") == class_name] - if letter_type: - filtered_letters = [l for l in filtered_letters if l.get("letter_type") == letter_type] - if status: - filtered_letters = [l for l in filtered_letters if l.get("status") == status] - - filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) - total = len(filtered_letters) - start = (page - 1) * page_size - paginated_letters = filtered_letters[start:start + page_size] - - return LetterListResponse( - letters=[LetterResponse(**l) for l in paginated_letters], - total=total, page=page, page_size=page_size - ) - - -@router.put("/{letter_id}", response_model=LetterResponse) -async def update_letter(letter_id: str, request: LetterUpdateRequest): - """Aktualisiert einen bestehenden Brief.""" - logger.info(f"Updating letter: {letter_id}") - letter_data = _get_letter(letter_id) - - update_data = request.model_dump(exclude_unset=True) - for key, value in update_data.items(): - if value is not None: - if key == "school_info" and value: - letter_data[key] = value if isinstance(value, dict) else value.model_dump() - elif key == "legal_references" and value: - letter_data[key] = [ref if isinstance(ref, dict) else ref.model_dump() for ref in value] - else: - letter_data[key] = value - - _save_letter(letter_data) - return LetterResponse(**letter_data) - - -@router.delete("/{letter_id}") -async def delete_letter(letter_id: str): - """Loescht einen Brief.""" - logger.info(f"Deleting letter: {letter_id}") - if letter_id not in _letters_store: - raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") - del _letters_store[letter_id] - return {"message": f"Brief {letter_id} wurde geloescht"} - - -@router.post("/export-pdf") -async def export_letter_pdf(request: ExportPDFRequest): - """Exportiert einen Brief als PDF.""" - logger.info("Exporting letter as PDF") - - if request.letter_id: - letter_data = _get_letter(request.letter_id) - elif request.letter_data: - letter_data = request.letter_data.model_dump() - else: - raise HTTPException(status_code=400, detail="Entweder letter_id oder letter_data muss angegeben werden") - - if "date" not in letter_data: - letter_data["date"] = datetime.now().strftime("%d.%m.%Y") - - try: - pdf_bytes = generate_letter_pdf(letter_data) - except Exception as e: - logger.error(f"Error generating PDF: {e}") - raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}") - - student_name = letter_data.get("student_name", "Brief").replace(" ", "_") - date_str = datetime.now().strftime("%Y%m%d") - filename = f"Elternbrief_{student_name}_{date_str}.pdf" - - return Response( - content=pdf_bytes, media_type="application/pdf", - headers={"Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes))} - ) - - -@router.post("/{letter_id}/export-pdf") -async def export_saved_letter_pdf(letter_id: str): - """Exportiert einen gespeicherten Brief als PDF (Kurzform).""" - return await export_letter_pdf(ExportPDFRequest(letter_id=letter_id)) - - -@router.post("/improve", response_model=ImproveResponse) -async def improve_letter_content(request: ImproveRequest): - """Verbessert den Briefinhalt nach GFK-Prinzipien.""" - logger.info("Improving letter content with GFK principles") - - comm_service_url = os.getenv("COMMUNICATION_SERVICE_URL", "http://localhost:8000/v1/communication") - - try: - async with httpx.AsyncClient() as client: - validate_response = await client.post( - f"{comm_service_url}/validate", - json={"text": request.content}, timeout=30.0 - ) - - if validate_response.status_code != 200: - logger.warning(f"Validation service returned {validate_response.status_code}") - return ImproveResponse( - improved_content=request.content, - changes=["Verbesserungsservice nicht verfuegbar"], - gfk_score=0.5, gfk_principles_applied=[] - ) - - validation_data = validate_response.json() - - if validation_data.get("is_valid", False) and validation_data.get("gfk_score", 0) > 0.8: - return ImproveResponse( - improved_content=request.content, - changes=["Text entspricht bereits GFK-Standards"], - gfk_score=validation_data.get("gfk_score", 0.8), - gfk_principles_applied=validation_data.get("positive_elements", []) - ) - - return ImproveResponse( - improved_content=request.content, - changes=validation_data.get("suggestions", []), - gfk_score=validation_data.get("gfk_score", 0.5), - gfk_principles_applied=validation_data.get("positive_elements", []) - ) - - except httpx.TimeoutException: - logger.error("Timeout while calling communication service") - return ImproveResponse( - improved_content=request.content, - changes=["Zeitueberschreitung beim Verbesserungsservice"], - gfk_score=0.5, gfk_principles_applied=[] - ) - except Exception as e: - logger.error(f"Error improving content: {e}") - return ImproveResponse( - improved_content=request.content, - changes=[f"Fehler: {str(e)}"], - gfk_score=0.5, gfk_principles_applied=[] - ) - - -@router.post("/{letter_id}/send", response_model=SendEmailResponse) -async def send_letter_email(letter_id: str, request: SendEmailRequest): - """Versendet einen Brief per Email.""" - logger.info(f"Sending letter {letter_id} to {request.recipient_email}") - letter_data = _get_letter(letter_id) - - try: - pdf_attachment = None - if request.include_pdf: - letter_data["date"] = datetime.now().strftime("%d.%m.%Y") - pdf_bytes = generate_letter_pdf(letter_data) - pdf_attachment = { - "filename": f"Elternbrief_{letter_data.get('student_name', 'Brief').replace(' ', '_')}.pdf", - "content": pdf_bytes.hex(), - "content_type": "application/pdf" - } - - async with httpx.AsyncClient() as client: - logger.info(f"Would send email: {letter_data.get('subject')} to {request.recipient_email}") - letter_data["status"] = LetterStatus.SENT - letter_data["sent_at"] = datetime.now() - _save_letter(letter_data) - - return SendEmailResponse( - success=True, - message=f"Brief wurde an {request.recipient_email} gesendet", - sent_at=datetime.now() - ) - - except Exception as e: - logger.error(f"Error sending email: {e}") - return SendEmailResponse(success=False, message=f"Fehler beim Versenden: {str(e)}", sent_at=None) - - -@router.get("/student/{student_id}", response_model=LetterListResponse) -async def get_letters_for_student( - student_id: str, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100) -): - """Laedt alle Briefe fuer einen bestimmten Schueler.""" - logger.info(f"Getting letters for student: {student_id}") - - filtered_letters = [ - l for l in _letters_store.values() - if student_id.lower() in l.get("student_name", "").lower() - ] - - filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) - total = len(filtered_letters) - start = (page - 1) * page_size - paginated_letters = filtered_letters[start:start + page_size] - - return LetterListResponse( - letters=[LetterResponse(**l) for l in paginated_letters], - total=total, page=page, page_size=page_size - ) +# Backward-compat shim -- module moved to letters/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("letters.api") diff --git a/backend-lehrer/letters_models.py b/backend-lehrer/letters_models.py index f45565e..c3df8dc 100644 --- a/backend-lehrer/letters_models.py +++ b/backend-lehrer/letters_models.py @@ -1,195 +1,4 @@ -""" -Letters Models - Pydantic models and enums for Elternbrief-Verwaltung. -""" -from datetime import datetime -from typing import Optional, List -from enum import Enum - -from pydantic import BaseModel, Field - - -# ============================================================================= -# Enums -# ============================================================================= - -class LetterType(str, Enum): - """Typen von Elternbriefen.""" - GENERAL = "general" - HALBJAHR = "halbjahr" - FEHLZEITEN = "fehlzeiten" - ELTERNABEND = "elternabend" - LOB = "lob" - CUSTOM = "custom" - - -class LetterTone(str, Enum): - """Tonalitaet der Briefe.""" - FORMAL = "formal" - PROFESSIONAL = "professional" - WARM = "warm" - CONCERNED = "concerned" - APPRECIATIVE = "appreciative" - - -class LetterStatus(str, Enum): - """Status eines Briefes.""" - DRAFT = "draft" - SENT = "sent" - ARCHIVED = "archived" - - -# ============================================================================= -# Pydantic Models -# ============================================================================= - -class SchoolInfoModel(BaseModel): - """Schulinformationen fuer Briefkopf.""" - name: str - address: str - phone: str - email: str - website: Optional[str] = None - principal: Optional[str] = None - logo_path: Optional[str] = None - - -class LegalReferenceModel(BaseModel): - """Rechtliche Referenz.""" - law: str - paragraph: str - title: str - summary: Optional[str] = None - relevance: Optional[str] = None - - -class LetterCreateRequest(BaseModel): - """Request zum Erstellen eines neuen Briefes.""" - recipient_name: str = Field(..., description="Name des Empfaengers") - recipient_address: str = Field(..., description="Adresse des Empfaengers") - student_name: str = Field(..., description="Name des Schuelers") - student_class: str = Field(..., description="Klasse des Schuelers") - subject: str = Field(..., description="Betreff des Briefes") - content: str = Field(..., description="Inhalt des Briefes") - letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes") - tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalitaet des Briefes") - teacher_name: str = Field(..., description="Name des Lehrers") - teacher_title: Optional[str] = Field(None, description="Titel des Lehrers") - school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen") - legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen") - gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien") - - -class LetterUpdateRequest(BaseModel): - """Request zum Aktualisieren eines Briefes.""" - recipient_name: Optional[str] = None - recipient_address: Optional[str] = None - student_name: Optional[str] = None - student_class: Optional[str] = None - subject: Optional[str] = None - content: Optional[str] = None - letter_type: Optional[LetterType] = None - tone: Optional[LetterTone] = None - teacher_name: Optional[str] = None - teacher_title: Optional[str] = None - school_info: Optional[SchoolInfoModel] = None - legal_references: Optional[List[LegalReferenceModel]] = None - gfk_principles_applied: Optional[List[str]] = None - status: Optional[LetterStatus] = None - - -class LetterResponse(BaseModel): - """Response mit Briefdaten.""" - id: str - recipient_name: str - recipient_address: str - student_name: str - student_class: str - subject: str - content: str - letter_type: LetterType - tone: LetterTone - teacher_name: str - teacher_title: Optional[str] - school_info: Optional[SchoolInfoModel] - legal_references: Optional[List[LegalReferenceModel]] - gfk_principles_applied: Optional[List[str]] - gfk_score: Optional[float] - status: LetterStatus - pdf_path: Optional[str] - dsms_cid: Optional[str] - sent_at: Optional[datetime] - created_at: datetime - updated_at: datetime - - -class LetterListResponse(BaseModel): - """Response mit Liste von Briefen.""" - letters: List[LetterResponse] - total: int - page: int - page_size: int - - -class ExportPDFRequest(BaseModel): - """Request zum PDF-Export.""" - letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes") - letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten") - - -class ImproveRequest(BaseModel): - """Request zur GFK-Verbesserung.""" - content: str = Field(..., description="Text zur Verbesserung") - communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation") - tone: Optional[str] = Field("professional", description="Gewuenschte Tonalitaet") - - -class ImproveResponse(BaseModel): - """Response mit verbessertem Text.""" - improved_content: str - changes: List[str] - gfk_score: float - gfk_principles_applied: List[str] - - -class SendEmailRequest(BaseModel): - """Request zum Email-Versand.""" - letter_id: str - recipient_email: str - cc_emails: Optional[List[str]] = None - include_pdf: bool = True - - -class SendEmailResponse(BaseModel): - """Response nach Email-Versand.""" - success: bool - message: str - sent_at: Optional[datetime] - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def get_type_label(letter_type: LetterType) -> str: - """Gibt menschenlesbare Labels fuer Brieftypen zurueck.""" - labels = { - LetterType.GENERAL: "Allgemeine Information", - LetterType.HALBJAHR: "Halbjahresinformation", - LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung", - LetterType.ELTERNABEND: "Einladung Elternabend", - LetterType.LOB: "Positives Feedback", - LetterType.CUSTOM: "Benutzerdefiniert", - } - return labels.get(letter_type, letter_type.value) - - -def get_tone_label(tone: LetterTone) -> str: - """Gibt menschenlesbare Labels fuer Tonalitaeten zurueck.""" - labels = { - LetterTone.FORMAL: "Sehr foermlich", - LetterTone.PROFESSIONAL: "Professionell-freundlich", - LetterTone.WARM: "Warmherzig", - LetterTone.CONCERNED: "Besorgt", - LetterTone.APPRECIATIVE: "Wertschaetzend", - } - return labels.get(tone, tone.value) +# Backward-compat shim -- module moved to letters/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("letters.models") diff --git a/backend-lehrer/messenger/__init__.py b/backend-lehrer/messenger/__init__.py new file mode 100644 index 0000000..2cd2e95 --- /dev/null +++ b/backend-lehrer/messenger/__init__.py @@ -0,0 +1 @@ +# messenger — Kontakte, Konversationen, Nachrichten, Gruppen. diff --git a/backend-lehrer/messenger/api.py b/backend-lehrer/messenger/api.py new file mode 100644 index 0000000..ce130e3 --- /dev/null +++ b/backend-lehrer/messenger/api.py @@ -0,0 +1,21 @@ +""" +BreakPilot Messenger API — Barrel Re-export. + +Stellt Endpoints fuer Kontakte, Konversationen, Nachrichten, +CSV-Import, Gruppenmanagement und Templates bereit. + +Split into: + - messenger_models.py: Pydantic models + - messenger_helpers.py: JSON file storage & default templates + - messenger_contacts.py: Contact CRUD & CSV import/export + - messenger_conversations.py: Conversations, messages, groups, templates, stats +""" + +from fastapi import APIRouter + +from .contacts import router as _contacts_router +from .conversations import router as _conversations_router + +router = APIRouter(prefix="/api/messenger", tags=["Messenger"]) +router.include_router(_contacts_router) +router.include_router(_conversations_router) diff --git a/backend-lehrer/messenger/contacts.py b/backend-lehrer/messenger/contacts.py new file mode 100644 index 0000000..2ab317e --- /dev/null +++ b/backend-lehrer/messenger/contacts.py @@ -0,0 +1,251 @@ +""" +Messenger API - Contact Routes. + +CRUD, CSV import/export for contacts. +""" + +import csv +import uuid +from io import StringIO +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, UploadFile, File, Query +from fastapi.responses import StreamingResponse + +from .models import ( + Contact, + ContactCreate, + ContactUpdate, + CSVImportResult, +) +from .helpers import get_contacts, save_contacts + +router = APIRouter(tags=["Messenger"]) + + +# ========================================== +# CONTACTS ENDPOINTS +# ========================================== + +@router.get("/contacts", response_model=List[Contact]) +async def list_contacts( + role: Optional[str] = Query(None, description="Filter by role"), + class_name: Optional[str] = Query(None, description="Filter by class"), + search: Optional[str] = Query(None, description="Search in name/email") +): + """Listet alle Kontakte auf.""" + contacts = get_contacts() + + # Filter anwenden + if role: + contacts = [c for c in contacts if c.get("role") == role] + if class_name: + contacts = [c for c in contacts if c.get("class_name") == class_name] + if search: + search_lower = search.lower() + contacts = [c for c in contacts if + search_lower in c.get("name", "").lower() or + search_lower in (c.get("email") or "").lower() or + search_lower in (c.get("student_name") or "").lower()] + + return contacts + + +@router.post("/contacts", response_model=Contact) +async def create_contact(contact: ContactCreate): + """Erstellt einen neuen Kontakt.""" + contacts = get_contacts() + + # Pruefen ob Email bereits existiert + if contact.email: + existing = [c for c in contacts if c.get("email") == contact.email] + if existing: + raise HTTPException(status_code=400, detail="Kontakt mit dieser Email existiert bereits") + + now = datetime.utcnow().isoformat() + new_contact = { + "id": str(uuid.uuid4()), + "created_at": now, + "updated_at": now, + "online": False, + "last_seen": None, + **contact.dict() + } + + contacts.append(new_contact) + save_contacts(contacts) + + return new_contact + + +@router.get("/contacts/{contact_id}", response_model=Contact) +async def get_contact(contact_id: str): + """Ruft einen einzelnen Kontakt ab.""" + contacts = get_contacts() + contact = next((c for c in contacts if c["id"] == contact_id), None) + + if not contact: + raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") + + return contact + + +@router.put("/contacts/{contact_id}", response_model=Contact) +async def update_contact(contact_id: str, update: ContactUpdate): + """Aktualisiert einen Kontakt.""" + contacts = get_contacts() + contact_idx = next((i for i, c in enumerate(contacts) if c["id"] == contact_id), None) + + if contact_idx is None: + raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") + + update_data = update.dict(exclude_unset=True) + contacts[contact_idx].update(update_data) + contacts[contact_idx]["updated_at"] = datetime.utcnow().isoformat() + + save_contacts(contacts) + return contacts[contact_idx] + + +@router.delete("/contacts/{contact_id}") +async def delete_contact(contact_id: str): + """Loescht einen Kontakt.""" + contacts = get_contacts() + contacts = [c for c in contacts if c["id"] != contact_id] + save_contacts(contacts) + + return {"status": "deleted", "id": contact_id} + + +@router.post("/contacts/import", response_model=CSVImportResult) +async def import_contacts_csv(file: UploadFile = File(...)): + """ + Importiert Kontakte aus einer CSV-Datei. + + Erwartete Spalten: + - name (required) + - email + - phone + - role (parent/teacher/staff/student) + - student_name + - class_name + - notes + - tags (komma-separiert) + """ + if not file.filename.endswith('.csv'): + raise HTTPException(status_code=400, detail="Nur CSV-Dateien werden unterstuetzt") + + content = await file.read() + try: + text = content.decode('utf-8') + except UnicodeDecodeError: + text = content.decode('latin-1') + + contacts = get_contacts() + existing_emails = {c.get("email") for c in contacts if c.get("email")} + + imported = [] + skipped = 0 + errors = [] + + reader = csv.DictReader(StringIO(text), delimiter=';') # Deutsche CSV meist mit Semikolon + if not reader.fieldnames or 'name' not in [f.lower() for f in reader.fieldnames]: + # Versuche mit Komma + reader = csv.DictReader(StringIO(text), delimiter=',') + + for row_num, row in enumerate(reader, start=2): + try: + # Normalisiere Spaltennamen + row = {k.lower().strip(): v.strip() if v else "" for k, v in row.items()} + + name = row.get('name') or row.get('kontakt') or row.get('elternname') + if not name: + errors.append(f"Zeile {row_num}: Name fehlt") + skipped += 1 + continue + + email = row.get('email') or row.get('e-mail') or row.get('mail') + if email and email in existing_emails: + errors.append(f"Zeile {row_num}: Email {email} existiert bereits") + skipped += 1 + continue + + now = datetime.utcnow().isoformat() + tags_str = row.get('tags') or row.get('kategorien') or "" + tags = [t.strip() for t in tags_str.split(',') if t.strip()] + + # Matrix-ID und preferred_channel auslesen + matrix_id = row.get('matrix_id') or row.get('matrix') or None + preferred_channel = row.get('preferred_channel') or row.get('kanal') or "email" + if preferred_channel not in ["email", "matrix", "pwa"]: + preferred_channel = "email" + + new_contact = { + "id": str(uuid.uuid4()), + "name": name, + "email": email if email else None, + "phone": row.get('phone') or row.get('telefon') or row.get('tel'), + "role": row.get('role') or row.get('rolle') or "parent", + "student_name": row.get('student_name') or row.get('schueler') or row.get('kind'), + "class_name": row.get('class_name') or row.get('klasse'), + "notes": row.get('notes') or row.get('notizen') or row.get('bemerkungen'), + "tags": tags, + "matrix_id": matrix_id if matrix_id else None, + "preferred_channel": preferred_channel, + "created_at": now, + "updated_at": now, + "online": False, + "last_seen": None + } + + contacts.append(new_contact) + imported.append(new_contact) + if email: + existing_emails.add(email) + + except Exception as e: + errors.append(f"Zeile {row_num}: {str(e)}") + skipped += 1 + + save_contacts(contacts) + + return CSVImportResult( + imported=len(imported), + skipped=skipped, + errors=errors[:20], # Maximal 20 Fehler zurueckgeben + contacts=imported + ) + + +@router.get("/contacts/export/csv") +async def export_contacts_csv(): + """Exportiert alle Kontakte als CSV.""" + contacts = get_contacts() + + output = StringIO() + fieldnames = ['name', 'email', 'phone', 'role', 'student_name', 'class_name', 'notes', 'tags', 'matrix_id', 'preferred_channel'] + writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';') + writer.writeheader() + + for contact in contacts: + writer.writerow({ + 'name': contact.get('name', ''), + 'email': contact.get('email', ''), + 'phone': contact.get('phone', ''), + 'role': contact.get('role', ''), + 'student_name': contact.get('student_name', ''), + 'class_name': contact.get('class_name', ''), + 'notes': contact.get('notes', ''), + 'tags': ','.join(contact.get('tags', [])), + 'matrix_id': contact.get('matrix_id', ''), + 'preferred_channel': contact.get('preferred_channel', 'email') + }) + + output.seek(0) + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=kontakte.csv"} + ) diff --git a/backend-lehrer/messenger/conversations.py b/backend-lehrer/messenger/conversations.py new file mode 100644 index 0000000..b58e358 --- /dev/null +++ b/backend-lehrer/messenger/conversations.py @@ -0,0 +1,405 @@ +""" +Messenger API - Conversation, Message, Group, Template & Stats Routes. + +Conversations CRUD, message send/read, groups, templates, stats. +""" + +import uuid +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Query + +from .models import ( + Conversation, + Group, + GroupCreate, + Message, + MessageBase, +) +from .helpers import ( + DATA_DIR, + DEFAULT_TEMPLATES, + get_contacts, + get_conversations, + save_conversations, + get_messages, + save_messages, + get_groups, + save_groups, + load_json, + save_json, +) + +router = APIRouter(tags=["Messenger"]) + + +# ========================================== +# GROUPS ENDPOINTS +# ========================================== + +@router.get("/groups", response_model=List[Group]) +async def list_groups(): + """Listet alle Gruppen auf.""" + return get_groups() + + +@router.post("/groups", response_model=Group) +async def create_group(group: GroupCreate): + """Erstellt eine neue Gruppe.""" + groups = get_groups() + + now = datetime.utcnow().isoformat() + new_group = { + "id": str(uuid.uuid4()), + "created_at": now, + "updated_at": now, + **group.dict() + } + + groups.append(new_group) + save_groups(groups) + + return new_group + + +@router.put("/groups/{group_id}/members") +async def update_group_members(group_id: str, member_ids: List[str]): + """Aktualisiert die Mitglieder einer Gruppe.""" + groups = get_groups() + group_idx = next((i for i, g in enumerate(groups) if g["id"] == group_id), None) + + if group_idx is None: + raise HTTPException(status_code=404, detail="Gruppe nicht gefunden") + + groups[group_idx]["member_ids"] = member_ids + groups[group_idx]["updated_at"] = datetime.utcnow().isoformat() + + save_groups(groups) + return groups[group_idx] + + +@router.delete("/groups/{group_id}") +async def delete_group(group_id: str): + """Loescht eine Gruppe.""" + groups = get_groups() + groups = [g for g in groups if g["id"] != group_id] + save_groups(groups) + + return {"status": "deleted", "id": group_id} + + +# ========================================== +# CONVERSATIONS ENDPOINTS +# ========================================== + +@router.get("/conversations", response_model=List[Conversation]) +async def list_conversations(): + """Listet alle Konversationen auf.""" + conversations = get_conversations() + messages = get_messages() + + # Unread count und letzte Nachricht hinzufuegen + for conv in conversations: + conv_messages = [m for m in messages if m.get("conversation_id") == conv["id"]] + conv["unread_count"] = len([m for m in conv_messages if not m.get("read") and m.get("sender_id") != "self"]) + + if conv_messages: + last_msg = max(conv_messages, key=lambda m: m.get("timestamp", "")) + conv["last_message"] = last_msg.get("content", "")[:50] + conv["last_message_time"] = last_msg.get("timestamp") + + # Nach letzter Nachricht sortieren + conversations.sort(key=lambda c: c.get("last_message_time") or "", reverse=True) + + return conversations + + +@router.post("/conversations", response_model=Conversation) +async def create_conversation(contact_id: Optional[str] = None, group_id: Optional[str] = None): + """ + Erstellt eine neue Konversation. + Entweder mit einem Kontakt (1:1) oder einer Gruppe. + """ + conversations = get_conversations() + + if not contact_id and not group_id: + raise HTTPException(status_code=400, detail="Entweder contact_id oder group_id erforderlich") + + # Pruefen ob Konversation bereits existiert + if contact_id: + existing = next((c for c in conversations + if not c.get("is_group") and contact_id in c.get("participant_ids", [])), None) + if existing: + return existing + + now = datetime.utcnow().isoformat() + + if group_id: + groups = get_groups() + group = next((g for g in groups if g["id"] == group_id), None) + if not group: + raise HTTPException(status_code=404, detail="Gruppe nicht gefunden") + + new_conv = { + "id": str(uuid.uuid4()), + "name": group.get("name"), + "is_group": True, + "participant_ids": group.get("member_ids", []), + "group_id": group_id, + "created_at": now, + "updated_at": now, + "last_message": None, + "last_message_time": None, + "unread_count": 0 + } + else: + contacts = get_contacts() + contact = next((c for c in contacts if c["id"] == contact_id), None) + if not contact: + raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") + + new_conv = { + "id": str(uuid.uuid4()), + "name": contact.get("name"), + "is_group": False, + "participant_ids": [contact_id], + "group_id": None, + "created_at": now, + "updated_at": now, + "last_message": None, + "last_message_time": None, + "unread_count": 0 + } + + conversations.append(new_conv) + save_conversations(conversations) + + return new_conv + + +@router.get("/conversations/{conversation_id}", response_model=Conversation) +async def get_conversation(conversation_id: str): + """Ruft eine Konversation ab.""" + conversations = get_conversations() + conv = next((c for c in conversations if c["id"] == conversation_id), None) + + if not conv: + raise HTTPException(status_code=404, detail="Konversation nicht gefunden") + + return conv + + +@router.delete("/conversations/{conversation_id}") +async def delete_conversation(conversation_id: str): + """Loescht eine Konversation und alle zugehoerigen Nachrichten.""" + conversations = get_conversations() + conversations = [c for c in conversations if c["id"] != conversation_id] + save_conversations(conversations) + + messages = get_messages() + messages = [m for m in messages if m.get("conversation_id") != conversation_id] + save_messages(messages) + + return {"status": "deleted", "id": conversation_id} + + +# ========================================== +# MESSAGES ENDPOINTS +# ========================================== + +@router.get("/conversations/{conversation_id}/messages", response_model=List[Message]) +async def list_messages( + conversation_id: str, + limit: int = Query(50, ge=1, le=200), + before: Optional[str] = Query(None, description="Load messages before this timestamp") +): + """Ruft Nachrichten einer Konversation ab.""" + messages = get_messages() + conv_messages = [m for m in messages if m.get("conversation_id") == conversation_id] + + if before: + conv_messages = [m for m in conv_messages if m.get("timestamp", "") < before] + + # Nach Zeit sortieren (neueste zuletzt) + conv_messages.sort(key=lambda m: m.get("timestamp", "")) + + return conv_messages[-limit:] + + +@router.post("/conversations/{conversation_id}/messages", response_model=Message) +async def send_message(conversation_id: str, message: MessageBase): + """ + Sendet eine Nachricht in einer Konversation. + + Wenn send_email=True und der Kontakt eine Email-Adresse hat, + wird die Nachricht auch per Email versendet. + """ + conversations = get_conversations() + conv = next((c for c in conversations if c["id"] == conversation_id), None) + + if not conv: + raise HTTPException(status_code=404, detail="Konversation nicht gefunden") + + now = datetime.utcnow().isoformat() + + new_message = { + "id": str(uuid.uuid4()), + "conversation_id": conversation_id, + "sender_id": "self", + "timestamp": now, + "read": True, + "read_at": now, + "email_sent": False, + "email_sent_at": None, + "email_error": None, + **message.dict() + } + + # Email-Versand wenn gewuenscht + if message.send_email and not conv.get("is_group"): + # Kontakt laden + participant_ids = conv.get("participant_ids", []) + if participant_ids: + contacts = get_contacts() + contact = next((c for c in contacts if c["id"] == participant_ids[0]), None) + + if contact and contact.get("email"): + try: + from email_service import email_service + + result = email_service.send_messenger_notification( + to_email=contact["email"], + to_name=contact.get("name", ""), + sender_name="BreakPilot Lehrer", + message_content=message.content + ) + + if result.success: + new_message["email_sent"] = True + new_message["email_sent_at"] = result.sent_at + else: + new_message["email_error"] = result.error + + except Exception as e: + new_message["email_error"] = str(e) + + messages = get_messages() + messages.append(new_message) + save_messages(messages) + + # Konversation aktualisieren + conv_idx = next(i for i, c in enumerate(conversations) if c["id"] == conversation_id) + conversations[conv_idx]["last_message"] = message.content[:50] + conversations[conv_idx]["last_message_time"] = now + conversations[conv_idx]["updated_at"] = now + save_conversations(conversations) + + return new_message + + +@router.put("/messages/{message_id}/read") +async def mark_message_read(message_id: str): + """Markiert eine Nachricht als gelesen.""" + messages = get_messages() + msg_idx = next((i for i, m in enumerate(messages) if m["id"] == message_id), None) + + if msg_idx is None: + raise HTTPException(status_code=404, detail="Nachricht nicht gefunden") + + messages[msg_idx]["read"] = True + messages[msg_idx]["read_at"] = datetime.utcnow().isoformat() + save_messages(messages) + + return {"status": "read", "id": message_id} + + +@router.put("/conversations/{conversation_id}/read-all") +async def mark_all_messages_read(conversation_id: str): + """Markiert alle Nachrichten einer Konversation als gelesen.""" + messages = get_messages() + now = datetime.utcnow().isoformat() + + for msg in messages: + if msg.get("conversation_id") == conversation_id and not msg.get("read"): + msg["read"] = True + msg["read_at"] = now + + save_messages(messages) + + return {"status": "all_read", "conversation_id": conversation_id} + + +# ========================================== +# TEMPLATES ENDPOINTS +# ========================================== + +@router.get("/templates") +async def list_templates(): + """Listet alle Nachrichtenvorlagen auf.""" + templates_file = DATA_DIR / "templates.json" + if templates_file.exists(): + templates = load_json(templates_file) + else: + templates = DEFAULT_TEMPLATES + save_json(templates_file, templates) + + return templates + + +@router.post("/templates") +async def create_template(name: str, content: str, category: str = "custom"): + """Erstellt eine neue Vorlage.""" + templates_file = DATA_DIR / "templates.json" + templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy() + + new_template = { + "id": str(uuid.uuid4()), + "name": name, + "content": content, + "category": category + } + + templates.append(new_template) + save_json(templates_file, templates) + + return new_template + + +@router.delete("/templates/{template_id}") +async def delete_template(template_id: str): + """Loescht eine Vorlage.""" + templates_file = DATA_DIR / "templates.json" + templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy() + + templates = [t for t in templates if t["id"] != template_id] + save_json(templates_file, templates) + + return {"status": "deleted", "id": template_id} + + +# ========================================== +# STATS ENDPOINT +# ========================================== + +@router.get("/stats") +async def get_messenger_stats(): + """Gibt Statistiken zum Messenger zurueck.""" + contacts = get_contacts() + conversations = get_conversations() + messages = get_messages() + groups = get_groups() + + unread_total = sum(1 for m in messages if not m.get("read") and m.get("sender_id") != "self") + + return { + "total_contacts": len(contacts), + "total_groups": len(groups), + "total_conversations": len(conversations), + "total_messages": len(messages), + "unread_messages": unread_total, + "contacts_by_role": { + role: len([c for c in contacts if c.get("role") == role]) + for role in set(c.get("role", "parent") for c in contacts) + } + } diff --git a/backend-lehrer/messenger/helpers.py b/backend-lehrer/messenger/helpers.py new file mode 100644 index 0000000..1e4725f --- /dev/null +++ b/backend-lehrer/messenger/helpers.py @@ -0,0 +1,105 @@ +""" +Messenger API - Data Helpers. + +JSON-based file storage for contacts, conversations, messages, and groups. +""" + +import json +from typing import List, Dict +from pathlib import Path + +# Datenspeicherung (JSON-basiert fuer einfache Persistenz) +DATA_DIR = Path(__file__).parent / "data" / "messenger" +DATA_DIR.mkdir(parents=True, exist_ok=True) + +CONTACTS_FILE = DATA_DIR / "contacts.json" +CONVERSATIONS_FILE = DATA_DIR / "conversations.json" +MESSAGES_FILE = DATA_DIR / "messages.json" +GROUPS_FILE = DATA_DIR / "groups.json" + + +def load_json(filepath: Path) -> List[Dict]: + """Laedt JSON-Daten aus Datei.""" + if not filepath.exists(): + return [] + try: + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return [] + + +def save_json(filepath: Path, data: List[Dict]): + """Speichert Daten in JSON-Datei.""" + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def get_contacts() -> List[Dict]: + return load_json(CONTACTS_FILE) + + +def save_contacts(contacts: List[Dict]): + save_json(CONTACTS_FILE, contacts) + + +def get_conversations() -> List[Dict]: + return load_json(CONVERSATIONS_FILE) + + +def save_conversations(conversations: List[Dict]): + save_json(CONVERSATIONS_FILE, conversations) + + +def get_messages() -> List[Dict]: + return load_json(MESSAGES_FILE) + + +def save_messages(messages: List[Dict]): + save_json(MESSAGES_FILE, messages) + + +def get_groups() -> List[Dict]: + return load_json(GROUPS_FILE) + + +def save_groups(groups: List[Dict]): + save_json(GROUPS_FILE, groups) + + +# ========================================== +# DEFAULT TEMPLATES +# ========================================== + +DEFAULT_TEMPLATES = [ + { + "id": "1", + "name": "Terminbestaetigung", + "content": "Vielen Dank fuer Ihre Terminanfrage. Ich bestaetige den Termin am [DATUM] um [UHRZEIT]. Bitte geben Sie mir Bescheid, falls sich etwas aendern sollte.", + "category": "termin" + }, + { + "id": "2", + "name": "Hausaufgaben-Info", + "content": "Zur Information: Die Hausaufgaben fuer diese Woche umfassen [THEMA]. Abgabetermin ist [DATUM]. Bei Fragen stehe ich gerne zur Verfuegung.", + "category": "hausaufgaben" + }, + { + "id": "3", + "name": "Entschuldigung bestaetigen", + "content": "Ich bestaetige den Erhalt der Entschuldigung fuer [NAME] am [DATUM]. Die Fehlzeiten wurden entsprechend vermerkt.", + "category": "entschuldigung" + }, + { + "id": "4", + "name": "Gespraechsanfrage", + "content": "Ich wuerde gerne einen Termin fuer ein Gespraech mit Ihnen vereinbaren, um [THEMA] zu besprechen. Waeren Sie am [DATUM] um [UHRZEIT] verfuegbar?", + "category": "gespraech" + }, + { + "id": "5", + "name": "Krankmeldung bestaetigen", + "content": "Vielen Dank fuer Ihre Krankmeldung fuer [NAME]. Ich wuensche gute Besserung. Bitte reichen Sie eine schriftliche Entschuldigung nach, sobald Ihr Kind wieder gesund ist.", + "category": "krankmeldung" + } +] diff --git a/backend-lehrer/messenger/models.py b/backend-lehrer/messenger/models.py new file mode 100644 index 0000000..275c700 --- /dev/null +++ b/backend-lehrer/messenger/models.py @@ -0,0 +1,139 @@ +""" +Messenger API - Pydantic Models. + +Data models for contacts, conversations, messages, and groups. +""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# ========================================== +# CONTACT MODELS +# ========================================== + +class ContactBase(BaseModel): + """Basis-Modell fuer Kontakte.""" + name: str = Field(..., min_length=1, max_length=200) + email: Optional[str] = None + phone: Optional[str] = None + role: str = Field(default="parent", description="parent, teacher, staff, student") + student_name: Optional[str] = Field(None, description="Name des zugehoerigen Schuelers") + class_name: Optional[str] = Field(None, description="Klasse z.B. 10a") + notes: Optional[str] = None + tags: List[str] = Field(default_factory=list) + matrix_id: Optional[str] = Field(None, description="Matrix-ID z.B. @user:matrix.org") + preferred_channel: str = Field(default="email", description="email, matrix, pwa") + + +class ContactCreate(ContactBase): + """Model fuer neuen Kontakt.""" + pass + + +class Contact(ContactBase): + """Vollstaendiger Kontakt mit ID.""" + id: str + created_at: str + updated_at: str + online: bool = False + last_seen: Optional[str] = None + + +class ContactUpdate(BaseModel): + """Update-Model fuer Kontakte.""" + name: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + role: Optional[str] = None + student_name: Optional[str] = None + class_name: Optional[str] = None + notes: Optional[str] = None + tags: Optional[List[str]] = None + matrix_id: Optional[str] = None + preferred_channel: Optional[str] = None + + +# ========================================== +# GROUP MODELS +# ========================================== + +class GroupBase(BaseModel): + """Basis-Modell fuer Gruppen.""" + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + group_type: str = Field(default="class", description="class, department, custom") + + +class GroupCreate(GroupBase): + """Model fuer neue Gruppe.""" + member_ids: List[str] = Field(default_factory=list) + + +class Group(GroupBase): + """Vollstaendige Gruppe mit ID.""" + id: str + member_ids: List[str] = [] + created_at: str + updated_at: str + + +# ========================================== +# MESSAGE MODELS +# ========================================== + +class MessageBase(BaseModel): + """Basis-Modell fuer Nachrichten.""" + content: str = Field(..., min_length=1) + content_type: str = Field(default="text", description="text, file, image") + file_url: Optional[str] = None + send_email: bool = Field(default=False, description="Nachricht auch per Email senden") + + +class MessageCreate(MessageBase): + """Model fuer neue Nachricht.""" + conversation_id: str + + +class Message(MessageBase): + """Vollstaendige Nachricht mit ID.""" + id: str + conversation_id: str + sender_id: str # "self" fuer eigene Nachrichten + timestamp: str + read: bool = False + read_at: Optional[str] = None + email_sent: bool = False + email_sent_at: Optional[str] = None + email_error: Optional[str] = None + + +# ========================================== +# CONVERSATION MODELS +# ========================================== + +class ConversationBase(BaseModel): + """Basis-Modell fuer Konversationen.""" + name: Optional[str] = None + is_group: bool = False + + +class Conversation(ConversationBase): + """Vollstaendige Konversation mit ID.""" + id: str + participant_ids: List[str] = [] + group_id: Optional[str] = None + created_at: str + updated_at: str + last_message: Optional[str] = None + last_message_time: Optional[str] = None + unread_count: int = 0 + + +class CSVImportResult(BaseModel): + """Ergebnis eines CSV-Imports.""" + imported: int + skipped: int + errors: List[str] + contacts: List[Contact] diff --git a/backend-lehrer/messenger_api.py b/backend-lehrer/messenger_api.py index ca5962f..0c530f0 100644 --- a/backend-lehrer/messenger_api.py +++ b/backend-lehrer/messenger_api.py @@ -1,21 +1,4 @@ -""" -BreakPilot Messenger API — Barrel Re-export. - -Stellt Endpoints fuer Kontakte, Konversationen, Nachrichten, -CSV-Import, Gruppenmanagement und Templates bereit. - -Split into: - - messenger_models.py: Pydantic models - - messenger_helpers.py: JSON file storage & default templates - - messenger_contacts.py: Contact CRUD & CSV import/export - - messenger_conversations.py: Conversations, messages, groups, templates, stats -""" - -from fastapi import APIRouter - -from messenger_contacts import router as _contacts_router -from messenger_conversations import router as _conversations_router - -router = APIRouter(prefix="/api/messenger", tags=["Messenger"]) -router.include_router(_contacts_router) -router.include_router(_conversations_router) +# Backward-compat shim -- module moved to messenger/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("messenger.api") diff --git a/backend-lehrer/messenger_contacts.py b/backend-lehrer/messenger_contacts.py index ae9625c..b81dba4 100644 --- a/backend-lehrer/messenger_contacts.py +++ b/backend-lehrer/messenger_contacts.py @@ -1,251 +1,4 @@ -""" -Messenger API - Contact Routes. - -CRUD, CSV import/export for contacts. -""" - -import csv -import uuid -from io import StringIO -from datetime import datetime -from typing import List, Optional - -from fastapi import APIRouter, HTTPException, UploadFile, File, Query -from fastapi.responses import StreamingResponse - -from messenger_models import ( - Contact, - ContactCreate, - ContactUpdate, - CSVImportResult, -) -from messenger_helpers import get_contacts, save_contacts - -router = APIRouter(tags=["Messenger"]) - - -# ========================================== -# CONTACTS ENDPOINTS -# ========================================== - -@router.get("/contacts", response_model=List[Contact]) -async def list_contacts( - role: Optional[str] = Query(None, description="Filter by role"), - class_name: Optional[str] = Query(None, description="Filter by class"), - search: Optional[str] = Query(None, description="Search in name/email") -): - """Listet alle Kontakte auf.""" - contacts = get_contacts() - - # Filter anwenden - if role: - contacts = [c for c in contacts if c.get("role") == role] - if class_name: - contacts = [c for c in contacts if c.get("class_name") == class_name] - if search: - search_lower = search.lower() - contacts = [c for c in contacts if - search_lower in c.get("name", "").lower() or - search_lower in (c.get("email") or "").lower() or - search_lower in (c.get("student_name") or "").lower()] - - return contacts - - -@router.post("/contacts", response_model=Contact) -async def create_contact(contact: ContactCreate): - """Erstellt einen neuen Kontakt.""" - contacts = get_contacts() - - # Pruefen ob Email bereits existiert - if contact.email: - existing = [c for c in contacts if c.get("email") == contact.email] - if existing: - raise HTTPException(status_code=400, detail="Kontakt mit dieser Email existiert bereits") - - now = datetime.utcnow().isoformat() - new_contact = { - "id": str(uuid.uuid4()), - "created_at": now, - "updated_at": now, - "online": False, - "last_seen": None, - **contact.dict() - } - - contacts.append(new_contact) - save_contacts(contacts) - - return new_contact - - -@router.get("/contacts/{contact_id}", response_model=Contact) -async def get_contact(contact_id: str): - """Ruft einen einzelnen Kontakt ab.""" - contacts = get_contacts() - contact = next((c for c in contacts if c["id"] == contact_id), None) - - if not contact: - raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") - - return contact - - -@router.put("/contacts/{contact_id}", response_model=Contact) -async def update_contact(contact_id: str, update: ContactUpdate): - """Aktualisiert einen Kontakt.""" - contacts = get_contacts() - contact_idx = next((i for i, c in enumerate(contacts) if c["id"] == contact_id), None) - - if contact_idx is None: - raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") - - update_data = update.dict(exclude_unset=True) - contacts[contact_idx].update(update_data) - contacts[contact_idx]["updated_at"] = datetime.utcnow().isoformat() - - save_contacts(contacts) - return contacts[contact_idx] - - -@router.delete("/contacts/{contact_id}") -async def delete_contact(contact_id: str): - """Loescht einen Kontakt.""" - contacts = get_contacts() - contacts = [c for c in contacts if c["id"] != contact_id] - save_contacts(contacts) - - return {"status": "deleted", "id": contact_id} - - -@router.post("/contacts/import", response_model=CSVImportResult) -async def import_contacts_csv(file: UploadFile = File(...)): - """ - Importiert Kontakte aus einer CSV-Datei. - - Erwartete Spalten: - - name (required) - - email - - phone - - role (parent/teacher/staff/student) - - student_name - - class_name - - notes - - tags (komma-separiert) - """ - if not file.filename.endswith('.csv'): - raise HTTPException(status_code=400, detail="Nur CSV-Dateien werden unterstuetzt") - - content = await file.read() - try: - text = content.decode('utf-8') - except UnicodeDecodeError: - text = content.decode('latin-1') - - contacts = get_contacts() - existing_emails = {c.get("email") for c in contacts if c.get("email")} - - imported = [] - skipped = 0 - errors = [] - - reader = csv.DictReader(StringIO(text), delimiter=';') # Deutsche CSV meist mit Semikolon - if not reader.fieldnames or 'name' not in [f.lower() for f in reader.fieldnames]: - # Versuche mit Komma - reader = csv.DictReader(StringIO(text), delimiter=',') - - for row_num, row in enumerate(reader, start=2): - try: - # Normalisiere Spaltennamen - row = {k.lower().strip(): v.strip() if v else "" for k, v in row.items()} - - name = row.get('name') or row.get('kontakt') or row.get('elternname') - if not name: - errors.append(f"Zeile {row_num}: Name fehlt") - skipped += 1 - continue - - email = row.get('email') or row.get('e-mail') or row.get('mail') - if email and email in existing_emails: - errors.append(f"Zeile {row_num}: Email {email} existiert bereits") - skipped += 1 - continue - - now = datetime.utcnow().isoformat() - tags_str = row.get('tags') or row.get('kategorien') or "" - tags = [t.strip() for t in tags_str.split(',') if t.strip()] - - # Matrix-ID und preferred_channel auslesen - matrix_id = row.get('matrix_id') or row.get('matrix') or None - preferred_channel = row.get('preferred_channel') or row.get('kanal') or "email" - if preferred_channel not in ["email", "matrix", "pwa"]: - preferred_channel = "email" - - new_contact = { - "id": str(uuid.uuid4()), - "name": name, - "email": email if email else None, - "phone": row.get('phone') or row.get('telefon') or row.get('tel'), - "role": row.get('role') or row.get('rolle') or "parent", - "student_name": row.get('student_name') or row.get('schueler') or row.get('kind'), - "class_name": row.get('class_name') or row.get('klasse'), - "notes": row.get('notes') or row.get('notizen') or row.get('bemerkungen'), - "tags": tags, - "matrix_id": matrix_id if matrix_id else None, - "preferred_channel": preferred_channel, - "created_at": now, - "updated_at": now, - "online": False, - "last_seen": None - } - - contacts.append(new_contact) - imported.append(new_contact) - if email: - existing_emails.add(email) - - except Exception as e: - errors.append(f"Zeile {row_num}: {str(e)}") - skipped += 1 - - save_contacts(contacts) - - return CSVImportResult( - imported=len(imported), - skipped=skipped, - errors=errors[:20], # Maximal 20 Fehler zurueckgeben - contacts=imported - ) - - -@router.get("/contacts/export/csv") -async def export_contacts_csv(): - """Exportiert alle Kontakte als CSV.""" - contacts = get_contacts() - - output = StringIO() - fieldnames = ['name', 'email', 'phone', 'role', 'student_name', 'class_name', 'notes', 'tags', 'matrix_id', 'preferred_channel'] - writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';') - writer.writeheader() - - for contact in contacts: - writer.writerow({ - 'name': contact.get('name', ''), - 'email': contact.get('email', ''), - 'phone': contact.get('phone', ''), - 'role': contact.get('role', ''), - 'student_name': contact.get('student_name', ''), - 'class_name': contact.get('class_name', ''), - 'notes': contact.get('notes', ''), - 'tags': ','.join(contact.get('tags', [])), - 'matrix_id': contact.get('matrix_id', ''), - 'preferred_channel': contact.get('preferred_channel', 'email') - }) - - output.seek(0) - - return StreamingResponse( - iter([output.getvalue()]), - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=kontakte.csv"} - ) +# Backward-compat shim -- module moved to messenger/contacts.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("messenger.contacts") diff --git a/backend-lehrer/messenger_conversations.py b/backend-lehrer/messenger_conversations.py index f7da2ed..440213c 100644 --- a/backend-lehrer/messenger_conversations.py +++ b/backend-lehrer/messenger_conversations.py @@ -1,405 +1,4 @@ -""" -Messenger API - Conversation, Message, Group, Template & Stats Routes. - -Conversations CRUD, message send/read, groups, templates, stats. -""" - -import uuid -from datetime import datetime -from typing import List, Optional - -from fastapi import APIRouter, HTTPException, Query - -from messenger_models import ( - Conversation, - Group, - GroupCreate, - Message, - MessageBase, -) -from messenger_helpers import ( - DATA_DIR, - DEFAULT_TEMPLATES, - get_contacts, - get_conversations, - save_conversations, - get_messages, - save_messages, - get_groups, - save_groups, - load_json, - save_json, -) - -router = APIRouter(tags=["Messenger"]) - - -# ========================================== -# GROUPS ENDPOINTS -# ========================================== - -@router.get("/groups", response_model=List[Group]) -async def list_groups(): - """Listet alle Gruppen auf.""" - return get_groups() - - -@router.post("/groups", response_model=Group) -async def create_group(group: GroupCreate): - """Erstellt eine neue Gruppe.""" - groups = get_groups() - - now = datetime.utcnow().isoformat() - new_group = { - "id": str(uuid.uuid4()), - "created_at": now, - "updated_at": now, - **group.dict() - } - - groups.append(new_group) - save_groups(groups) - - return new_group - - -@router.put("/groups/{group_id}/members") -async def update_group_members(group_id: str, member_ids: List[str]): - """Aktualisiert die Mitglieder einer Gruppe.""" - groups = get_groups() - group_idx = next((i for i, g in enumerate(groups) if g["id"] == group_id), None) - - if group_idx is None: - raise HTTPException(status_code=404, detail="Gruppe nicht gefunden") - - groups[group_idx]["member_ids"] = member_ids - groups[group_idx]["updated_at"] = datetime.utcnow().isoformat() - - save_groups(groups) - return groups[group_idx] - - -@router.delete("/groups/{group_id}") -async def delete_group(group_id: str): - """Loescht eine Gruppe.""" - groups = get_groups() - groups = [g for g in groups if g["id"] != group_id] - save_groups(groups) - - return {"status": "deleted", "id": group_id} - - -# ========================================== -# CONVERSATIONS ENDPOINTS -# ========================================== - -@router.get("/conversations", response_model=List[Conversation]) -async def list_conversations(): - """Listet alle Konversationen auf.""" - conversations = get_conversations() - messages = get_messages() - - # Unread count und letzte Nachricht hinzufuegen - for conv in conversations: - conv_messages = [m for m in messages if m.get("conversation_id") == conv["id"]] - conv["unread_count"] = len([m for m in conv_messages if not m.get("read") and m.get("sender_id") != "self"]) - - if conv_messages: - last_msg = max(conv_messages, key=lambda m: m.get("timestamp", "")) - conv["last_message"] = last_msg.get("content", "")[:50] - conv["last_message_time"] = last_msg.get("timestamp") - - # Nach letzter Nachricht sortieren - conversations.sort(key=lambda c: c.get("last_message_time") or "", reverse=True) - - return conversations - - -@router.post("/conversations", response_model=Conversation) -async def create_conversation(contact_id: Optional[str] = None, group_id: Optional[str] = None): - """ - Erstellt eine neue Konversation. - Entweder mit einem Kontakt (1:1) oder einer Gruppe. - """ - conversations = get_conversations() - - if not contact_id and not group_id: - raise HTTPException(status_code=400, detail="Entweder contact_id oder group_id erforderlich") - - # Pruefen ob Konversation bereits existiert - if contact_id: - existing = next((c for c in conversations - if not c.get("is_group") and contact_id in c.get("participant_ids", [])), None) - if existing: - return existing - - now = datetime.utcnow().isoformat() - - if group_id: - groups = get_groups() - group = next((g for g in groups if g["id"] == group_id), None) - if not group: - raise HTTPException(status_code=404, detail="Gruppe nicht gefunden") - - new_conv = { - "id": str(uuid.uuid4()), - "name": group.get("name"), - "is_group": True, - "participant_ids": group.get("member_ids", []), - "group_id": group_id, - "created_at": now, - "updated_at": now, - "last_message": None, - "last_message_time": None, - "unread_count": 0 - } - else: - contacts = get_contacts() - contact = next((c for c in contacts if c["id"] == contact_id), None) - if not contact: - raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") - - new_conv = { - "id": str(uuid.uuid4()), - "name": contact.get("name"), - "is_group": False, - "participant_ids": [contact_id], - "group_id": None, - "created_at": now, - "updated_at": now, - "last_message": None, - "last_message_time": None, - "unread_count": 0 - } - - conversations.append(new_conv) - save_conversations(conversations) - - return new_conv - - -@router.get("/conversations/{conversation_id}", response_model=Conversation) -async def get_conversation(conversation_id: str): - """Ruft eine Konversation ab.""" - conversations = get_conversations() - conv = next((c for c in conversations if c["id"] == conversation_id), None) - - if not conv: - raise HTTPException(status_code=404, detail="Konversation nicht gefunden") - - return conv - - -@router.delete("/conversations/{conversation_id}") -async def delete_conversation(conversation_id: str): - """Loescht eine Konversation und alle zugehoerigen Nachrichten.""" - conversations = get_conversations() - conversations = [c for c in conversations if c["id"] != conversation_id] - save_conversations(conversations) - - messages = get_messages() - messages = [m for m in messages if m.get("conversation_id") != conversation_id] - save_messages(messages) - - return {"status": "deleted", "id": conversation_id} - - -# ========================================== -# MESSAGES ENDPOINTS -# ========================================== - -@router.get("/conversations/{conversation_id}/messages", response_model=List[Message]) -async def list_messages( - conversation_id: str, - limit: int = Query(50, ge=1, le=200), - before: Optional[str] = Query(None, description="Load messages before this timestamp") -): - """Ruft Nachrichten einer Konversation ab.""" - messages = get_messages() - conv_messages = [m for m in messages if m.get("conversation_id") == conversation_id] - - if before: - conv_messages = [m for m in conv_messages if m.get("timestamp", "") < before] - - # Nach Zeit sortieren (neueste zuletzt) - conv_messages.sort(key=lambda m: m.get("timestamp", "")) - - return conv_messages[-limit:] - - -@router.post("/conversations/{conversation_id}/messages", response_model=Message) -async def send_message(conversation_id: str, message: MessageBase): - """ - Sendet eine Nachricht in einer Konversation. - - Wenn send_email=True und der Kontakt eine Email-Adresse hat, - wird die Nachricht auch per Email versendet. - """ - conversations = get_conversations() - conv = next((c for c in conversations if c["id"] == conversation_id), None) - - if not conv: - raise HTTPException(status_code=404, detail="Konversation nicht gefunden") - - now = datetime.utcnow().isoformat() - - new_message = { - "id": str(uuid.uuid4()), - "conversation_id": conversation_id, - "sender_id": "self", - "timestamp": now, - "read": True, - "read_at": now, - "email_sent": False, - "email_sent_at": None, - "email_error": None, - **message.dict() - } - - # Email-Versand wenn gewuenscht - if message.send_email and not conv.get("is_group"): - # Kontakt laden - participant_ids = conv.get("participant_ids", []) - if participant_ids: - contacts = get_contacts() - contact = next((c for c in contacts if c["id"] == participant_ids[0]), None) - - if contact and contact.get("email"): - try: - from email_service import email_service - - result = email_service.send_messenger_notification( - to_email=contact["email"], - to_name=contact.get("name", ""), - sender_name="BreakPilot Lehrer", - message_content=message.content - ) - - if result.success: - new_message["email_sent"] = True - new_message["email_sent_at"] = result.sent_at - else: - new_message["email_error"] = result.error - - except Exception as e: - new_message["email_error"] = str(e) - - messages = get_messages() - messages.append(new_message) - save_messages(messages) - - # Konversation aktualisieren - conv_idx = next(i for i, c in enumerate(conversations) if c["id"] == conversation_id) - conversations[conv_idx]["last_message"] = message.content[:50] - conversations[conv_idx]["last_message_time"] = now - conversations[conv_idx]["updated_at"] = now - save_conversations(conversations) - - return new_message - - -@router.put("/messages/{message_id}/read") -async def mark_message_read(message_id: str): - """Markiert eine Nachricht als gelesen.""" - messages = get_messages() - msg_idx = next((i for i, m in enumerate(messages) if m["id"] == message_id), None) - - if msg_idx is None: - raise HTTPException(status_code=404, detail="Nachricht nicht gefunden") - - messages[msg_idx]["read"] = True - messages[msg_idx]["read_at"] = datetime.utcnow().isoformat() - save_messages(messages) - - return {"status": "read", "id": message_id} - - -@router.put("/conversations/{conversation_id}/read-all") -async def mark_all_messages_read(conversation_id: str): - """Markiert alle Nachrichten einer Konversation als gelesen.""" - messages = get_messages() - now = datetime.utcnow().isoformat() - - for msg in messages: - if msg.get("conversation_id") == conversation_id and not msg.get("read"): - msg["read"] = True - msg["read_at"] = now - - save_messages(messages) - - return {"status": "all_read", "conversation_id": conversation_id} - - -# ========================================== -# TEMPLATES ENDPOINTS -# ========================================== - -@router.get("/templates") -async def list_templates(): - """Listet alle Nachrichtenvorlagen auf.""" - templates_file = DATA_DIR / "templates.json" - if templates_file.exists(): - templates = load_json(templates_file) - else: - templates = DEFAULT_TEMPLATES - save_json(templates_file, templates) - - return templates - - -@router.post("/templates") -async def create_template(name: str, content: str, category: str = "custom"): - """Erstellt eine neue Vorlage.""" - templates_file = DATA_DIR / "templates.json" - templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy() - - new_template = { - "id": str(uuid.uuid4()), - "name": name, - "content": content, - "category": category - } - - templates.append(new_template) - save_json(templates_file, templates) - - return new_template - - -@router.delete("/templates/{template_id}") -async def delete_template(template_id: str): - """Loescht eine Vorlage.""" - templates_file = DATA_DIR / "templates.json" - templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy() - - templates = [t for t in templates if t["id"] != template_id] - save_json(templates_file, templates) - - return {"status": "deleted", "id": template_id} - - -# ========================================== -# STATS ENDPOINT -# ========================================== - -@router.get("/stats") -async def get_messenger_stats(): - """Gibt Statistiken zum Messenger zurueck.""" - contacts = get_contacts() - conversations = get_conversations() - messages = get_messages() - groups = get_groups() - - unread_total = sum(1 for m in messages if not m.get("read") and m.get("sender_id") != "self") - - return { - "total_contacts": len(contacts), - "total_groups": len(groups), - "total_conversations": len(conversations), - "total_messages": len(messages), - "unread_messages": unread_total, - "contacts_by_role": { - role: len([c for c in contacts if c.get("role") == role]) - for role in set(c.get("role", "parent") for c in contacts) - } - } +# Backward-compat shim -- module moved to messenger/conversations.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("messenger.conversations") diff --git a/backend-lehrer/messenger_helpers.py b/backend-lehrer/messenger_helpers.py index 1e4725f..0a0ef56 100644 --- a/backend-lehrer/messenger_helpers.py +++ b/backend-lehrer/messenger_helpers.py @@ -1,105 +1,4 @@ -""" -Messenger API - Data Helpers. - -JSON-based file storage for contacts, conversations, messages, and groups. -""" - -import json -from typing import List, Dict -from pathlib import Path - -# Datenspeicherung (JSON-basiert fuer einfache Persistenz) -DATA_DIR = Path(__file__).parent / "data" / "messenger" -DATA_DIR.mkdir(parents=True, exist_ok=True) - -CONTACTS_FILE = DATA_DIR / "contacts.json" -CONVERSATIONS_FILE = DATA_DIR / "conversations.json" -MESSAGES_FILE = DATA_DIR / "messages.json" -GROUPS_FILE = DATA_DIR / "groups.json" - - -def load_json(filepath: Path) -> List[Dict]: - """Laedt JSON-Daten aus Datei.""" - if not filepath.exists(): - return [] - try: - with open(filepath, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return [] - - -def save_json(filepath: Path, data: List[Dict]): - """Speichert Daten in JSON-Datei.""" - with open(filepath, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def get_contacts() -> List[Dict]: - return load_json(CONTACTS_FILE) - - -def save_contacts(contacts: List[Dict]): - save_json(CONTACTS_FILE, contacts) - - -def get_conversations() -> List[Dict]: - return load_json(CONVERSATIONS_FILE) - - -def save_conversations(conversations: List[Dict]): - save_json(CONVERSATIONS_FILE, conversations) - - -def get_messages() -> List[Dict]: - return load_json(MESSAGES_FILE) - - -def save_messages(messages: List[Dict]): - save_json(MESSAGES_FILE, messages) - - -def get_groups() -> List[Dict]: - return load_json(GROUPS_FILE) - - -def save_groups(groups: List[Dict]): - save_json(GROUPS_FILE, groups) - - -# ========================================== -# DEFAULT TEMPLATES -# ========================================== - -DEFAULT_TEMPLATES = [ - { - "id": "1", - "name": "Terminbestaetigung", - "content": "Vielen Dank fuer Ihre Terminanfrage. Ich bestaetige den Termin am [DATUM] um [UHRZEIT]. Bitte geben Sie mir Bescheid, falls sich etwas aendern sollte.", - "category": "termin" - }, - { - "id": "2", - "name": "Hausaufgaben-Info", - "content": "Zur Information: Die Hausaufgaben fuer diese Woche umfassen [THEMA]. Abgabetermin ist [DATUM]. Bei Fragen stehe ich gerne zur Verfuegung.", - "category": "hausaufgaben" - }, - { - "id": "3", - "name": "Entschuldigung bestaetigen", - "content": "Ich bestaetige den Erhalt der Entschuldigung fuer [NAME] am [DATUM]. Die Fehlzeiten wurden entsprechend vermerkt.", - "category": "entschuldigung" - }, - { - "id": "4", - "name": "Gespraechsanfrage", - "content": "Ich wuerde gerne einen Termin fuer ein Gespraech mit Ihnen vereinbaren, um [THEMA] zu besprechen. Waeren Sie am [DATUM] um [UHRZEIT] verfuegbar?", - "category": "gespraech" - }, - { - "id": "5", - "name": "Krankmeldung bestaetigen", - "content": "Vielen Dank fuer Ihre Krankmeldung fuer [NAME]. Ich wuensche gute Besserung. Bitte reichen Sie eine schriftliche Entschuldigung nach, sobald Ihr Kind wieder gesund ist.", - "category": "krankmeldung" - } -] +# Backward-compat shim -- module moved to messenger/helpers.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("messenger.helpers") diff --git a/backend-lehrer/messenger_models.py b/backend-lehrer/messenger_models.py index 275c700..bcf8291 100644 --- a/backend-lehrer/messenger_models.py +++ b/backend-lehrer/messenger_models.py @@ -1,139 +1,4 @@ -""" -Messenger API - Pydantic Models. - -Data models for contacts, conversations, messages, and groups. -""" - -from typing import List, Optional - -from pydantic import BaseModel, Field - - -# ========================================== -# CONTACT MODELS -# ========================================== - -class ContactBase(BaseModel): - """Basis-Modell fuer Kontakte.""" - name: str = Field(..., min_length=1, max_length=200) - email: Optional[str] = None - phone: Optional[str] = None - role: str = Field(default="parent", description="parent, teacher, staff, student") - student_name: Optional[str] = Field(None, description="Name des zugehoerigen Schuelers") - class_name: Optional[str] = Field(None, description="Klasse z.B. 10a") - notes: Optional[str] = None - tags: List[str] = Field(default_factory=list) - matrix_id: Optional[str] = Field(None, description="Matrix-ID z.B. @user:matrix.org") - preferred_channel: str = Field(default="email", description="email, matrix, pwa") - - -class ContactCreate(ContactBase): - """Model fuer neuen Kontakt.""" - pass - - -class Contact(ContactBase): - """Vollstaendiger Kontakt mit ID.""" - id: str - created_at: str - updated_at: str - online: bool = False - last_seen: Optional[str] = None - - -class ContactUpdate(BaseModel): - """Update-Model fuer Kontakte.""" - name: Optional[str] = None - email: Optional[str] = None - phone: Optional[str] = None - role: Optional[str] = None - student_name: Optional[str] = None - class_name: Optional[str] = None - notes: Optional[str] = None - tags: Optional[List[str]] = None - matrix_id: Optional[str] = None - preferred_channel: Optional[str] = None - - -# ========================================== -# GROUP MODELS -# ========================================== - -class GroupBase(BaseModel): - """Basis-Modell fuer Gruppen.""" - name: str = Field(..., min_length=1, max_length=100) - description: Optional[str] = None - group_type: str = Field(default="class", description="class, department, custom") - - -class GroupCreate(GroupBase): - """Model fuer neue Gruppe.""" - member_ids: List[str] = Field(default_factory=list) - - -class Group(GroupBase): - """Vollstaendige Gruppe mit ID.""" - id: str - member_ids: List[str] = [] - created_at: str - updated_at: str - - -# ========================================== -# MESSAGE MODELS -# ========================================== - -class MessageBase(BaseModel): - """Basis-Modell fuer Nachrichten.""" - content: str = Field(..., min_length=1) - content_type: str = Field(default="text", description="text, file, image") - file_url: Optional[str] = None - send_email: bool = Field(default=False, description="Nachricht auch per Email senden") - - -class MessageCreate(MessageBase): - """Model fuer neue Nachricht.""" - conversation_id: str - - -class Message(MessageBase): - """Vollstaendige Nachricht mit ID.""" - id: str - conversation_id: str - sender_id: str # "self" fuer eigene Nachrichten - timestamp: str - read: bool = False - read_at: Optional[str] = None - email_sent: bool = False - email_sent_at: Optional[str] = None - email_error: Optional[str] = None - - -# ========================================== -# CONVERSATION MODELS -# ========================================== - -class ConversationBase(BaseModel): - """Basis-Modell fuer Konversationen.""" - name: Optional[str] = None - is_group: bool = False - - -class Conversation(ConversationBase): - """Vollstaendige Konversation mit ID.""" - id: str - participant_ids: List[str] = [] - group_id: Optional[str] = None - created_at: str - updated_at: str - last_message: Optional[str] = None - last_message_time: Optional[str] = None - unread_count: int = 0 - - -class CSVImportResult(BaseModel): - """Ergebnis eines CSV-Imports.""" - imported: int - skipped: int - errors: List[str] - contacts: List[Contact] +# Backward-compat shim -- module moved to messenger/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("messenger.models") diff --git a/backend-lehrer/recording/__init__.py b/backend-lehrer/recording/__init__.py new file mode 100644 index 0000000..0c2f7f0 --- /dev/null +++ b/backend-lehrer/recording/__init__.py @@ -0,0 +1 @@ +# recording — Meeting recordings, transcription, minutes. diff --git a/backend-lehrer/recording/api.py b/backend-lehrer/recording/api.py new file mode 100644 index 0000000..c4c6667 --- /dev/null +++ b/backend-lehrer/recording/api.py @@ -0,0 +1,22 @@ +""" +BreakPilot Recording API — Barrel Re-export. + +Verwaltet Jibri Meeting-Aufzeichnungen und deren Metadaten. +Split into: + - recording_models.py: Pydantic models & config + - recording_helpers.py: In-memory storage & utilities + - recording_routes.py: Core recording CRUD routes + - recording_transcription.py: Transcription routes + - recording_minutes.py: Meeting minutes routes +""" + +from fastapi import APIRouter + +from .routes import router as _routes_router +from .transcription import router as _transcription_router +from .minutes import router as _minutes_router + +router = APIRouter(prefix="/api/recordings", tags=["Recordings"]) +router.include_router(_routes_router) +router.include_router(_transcription_router) +router.include_router(_minutes_router) diff --git a/backend-lehrer/recording/helpers.py b/backend-lehrer/recording/helpers.py new file mode 100644 index 0000000..116add6 --- /dev/null +++ b/backend-lehrer/recording/helpers.py @@ -0,0 +1,57 @@ +""" +Recording API - In-Memory Storage & Helpers. + +Shared state and utility functions for recording endpoints. +""" + +import uuid +from datetime import datetime +from typing import Optional + + +# ========================================== +# IN-MEMORY STORAGE (Dev Mode) +# ========================================== +# In production, these would be database queries + +_recordings_store: dict = {} +_transcriptions_store: dict = {} +_audit_log: list = [] +_minutes_store: dict = {} + + +def log_audit( + action: str, + recording_id: Optional[str] = None, + transcription_id: Optional[str] = None, + user_id: Optional[str] = None, + metadata: Optional[dict] = None +): + """Log audit event for DSGVO compliance.""" + _audit_log.append({ + "id": str(uuid.uuid4()), + "recording_id": recording_id, + "transcription_id": transcription_id, + "user_id": user_id, + "action": action, + "metadata": metadata or {}, + "created_at": datetime.utcnow().isoformat() + }) + + +def format_vtt_time(ms: int) -> str: + """Format milliseconds to VTT timestamp (HH:MM:SS.mmm).""" + hours = ms // 3600000 + minutes = (ms % 3600000) // 60000 + seconds = (ms % 60000) // 1000 + millis = ms % 1000 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}" + + +def format_srt_time(ms: int) -> str: + """Format milliseconds to SRT timestamp (HH:MM:SS,mmm).""" + hours = ms // 3600000 + minutes = (ms % 3600000) // 60000 + seconds = (ms % 60000) // 1000 + millis = ms % 1000 + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}" diff --git a/backend-lehrer/recording/minutes.py b/backend-lehrer/recording/minutes.py new file mode 100644 index 0000000..3960938 --- /dev/null +++ b/backend-lehrer/recording/minutes.py @@ -0,0 +1,187 @@ +""" +Recording API - Meeting Minutes Routes. + +Generate, retrieve, and export KI-based meeting minutes. +""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import PlainTextResponse, HTMLResponse + +from .helpers import ( + _recordings_store, + _transcriptions_store, + _minutes_store, + log_audit, +) + +router = APIRouter(tags=["Recordings"]) + + +# ========================================== +# MEETING MINUTES ENDPOINTS +# ========================================== + +@router.post("/{recording_id}/minutes") +async def generate_meeting_minutes( + recording_id: str, + title: Optional[str] = Query(None, description="Meeting-Titel"), + model: str = Query("breakpilot-teacher-8b", description="LLM Modell") +): + """ + Generiert KI-basierte Meeting Minutes aus der Transkription. + + Nutzt das LLM Gateway (Ollama/vLLM) fuer lokale Verarbeitung. + """ + from meeting_minutes_generator import get_minutes_generator, MeetingMinutes + + # Check recording exists + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + # Check transcription exists and is completed + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=400, detail="No transcription found. Please transcribe first.") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + # Check if minutes already exist + existing = _minutes_store.get(recording_id) + if existing and existing.get("status") == "completed": + # Return existing minutes + return existing + + # Get transcript text + transcript_text = transcription.get("full_text", "") + if not transcript_text: + raise HTTPException(status_code=400, detail="Transcription has no text content") + + # Generate meeting minutes + generator = get_minutes_generator() + + try: + minutes = await generator.generate( + transcript=transcript_text, + recording_id=recording_id, + transcription_id=transcription["id"], + title=title, + date=recording.get("recorded_at", "")[:10] if recording.get("recorded_at") else None, + duration_minutes=recording.get("duration_seconds", 0) // 60 if recording.get("duration_seconds") else None, + participant_count=recording.get("participant_count", 0), + model=model + ) + + # Store minutes + minutes_dict = minutes.model_dump() + minutes_dict["generated_at"] = minutes.generated_at.isoformat() + _minutes_store[recording_id] = minutes_dict + + # Log action + log_audit( + action="minutes_generated", + recording_id=recording_id, + metadata={"model": model, "generation_time": minutes.generation_time_seconds} + ) + + return minutes_dict + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Minutes generation failed: {str(e)}") + + +@router.get("/{recording_id}/minutes") +async def get_meeting_minutes(recording_id: str): + """ + Ruft generierte Meeting Minutes ab. + """ + minutes = _minutes_store.get(recording_id) + if not minutes: + raise HTTPException(status_code=404, detail="No meeting minutes found. Generate them first with POST.") + + return minutes + + +def _load_minutes(recording_id: str): + """Load and convert stored minutes dict back to MeetingMinutes.""" + from meeting_minutes_generator import MeetingMinutes + + minutes_dict = _minutes_store.get(recording_id) + if not minutes_dict: + raise HTTPException(status_code=404, detail="No meeting minutes found") + + minutes_dict_copy = minutes_dict.copy() + if isinstance(minutes_dict_copy.get("generated_at"), str): + minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) + + return MeetingMinutes(**minutes_dict_copy) + + +@router.get("/{recording_id}/minutes/markdown") +async def get_minutes_markdown(recording_id: str): + """ + Exportiert Meeting Minutes als Markdown. + """ + from meeting_minutes_generator import minutes_to_markdown + + minutes = _load_minutes(recording_id) + markdown = minutes_to_markdown(minutes) + + return PlainTextResponse( + content=markdown, + media_type="text/markdown", + headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.md"} + ) + + +@router.get("/{recording_id}/minutes/html") +async def get_minutes_html(recording_id: str): + """ + Exportiert Meeting Minutes als HTML. + """ + from meeting_minutes_generator import minutes_to_html + + minutes = _load_minutes(recording_id) + html = minutes_to_html(minutes) + + return HTMLResponse(content=html) + + +@router.get("/{recording_id}/minutes/pdf") +async def get_minutes_pdf(recording_id: str): + """ + Exportiert Meeting Minutes als PDF. + + Benoetigt WeasyPrint (pip install weasyprint). + """ + from meeting_minutes_generator import minutes_to_html + + minutes = _load_minutes(recording_id) + html = minutes_to_html(minutes) + + try: + from weasyprint import HTML + from fastapi.responses import Response + + pdf_bytes = HTML(string=html).write_pdf() + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.pdf"} + ) + except ImportError: + raise HTTPException( + status_code=501, + detail="PDF export not available. Install weasyprint: pip install weasyprint" + ) diff --git a/backend-lehrer/recording/models.py b/backend-lehrer/recording/models.py new file mode 100644 index 0000000..4275ae8 --- /dev/null +++ b/backend-lehrer/recording/models.py @@ -0,0 +1,98 @@ +""" +Recording API - Pydantic Models & Configuration. + +Data models for recording, transcription, and webhook endpoints. +""" + +import os +from datetime import datetime +from typing import Optional, List + +from pydantic import BaseModel, Field + + +# ========================================== +# ENVIRONMENT CONFIGURATION +# ========================================== + +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-recordings") +MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" + +# Default retention period in days (DSGVO compliance) +DEFAULT_RETENTION_DAYS = int(os.getenv("RECORDING_RETENTION_DAYS", "365")) + + +# ========================================== +# PYDANTIC MODELS +# ========================================== + +class JibriWebhookPayload(BaseModel): + """Webhook payload from Jibri finalize.sh script.""" + event: str = Field(..., description="Event type: recording_completed") + recording_name: str = Field(..., description="Unique recording identifier") + storage_path: str = Field(..., description="Path in MinIO bucket") + audio_path: Optional[str] = Field(None, description="Extracted audio path") + file_size_bytes: int = Field(..., description="Video file size in bytes") + timestamp: str = Field(..., description="ISO timestamp of upload") + + +class RecordingCreate(BaseModel): + """Manual recording creation (for testing).""" + meeting_id: str + title: Optional[str] = None + storage_path: str + audio_path: Optional[str] = None + duration_seconds: Optional[int] = None + participant_count: Optional[int] = 0 + retention_days: Optional[int] = DEFAULT_RETENTION_DAYS + + +class RecordingResponse(BaseModel): + """Recording details response.""" + id: str + meeting_id: str + title: Optional[str] + storage_path: str + audio_path: Optional[str] + file_size_bytes: Optional[int] + duration_seconds: Optional[int] + participant_count: int + status: str + recorded_at: datetime + retention_days: int + retention_expires_at: datetime + transcription_status: Optional[str] = None + transcription_id: Optional[str] = None + + +class RecordingListResponse(BaseModel): + """Paginated list of recordings.""" + recordings: List[RecordingResponse] + total: int + page: int + page_size: int + + +class TranscriptionRequest(BaseModel): + """Request to start transcription.""" + language: str = Field(default="de", description="Language code: de, en, etc.") + model: str = Field(default="large-v3", description="Whisper model to use") + priority: int = Field(default=0, description="Queue priority (higher = sooner)") + + +class TranscriptionStatusResponse(BaseModel): + """Transcription status and progress.""" + id: str + recording_id: str + status: str + language: str + model: str + word_count: Optional[int] + confidence_score: Optional[float] + processing_duration_seconds: Optional[int] + error_message: Optional[str] + created_at: datetime + completed_at: Optional[datetime] diff --git a/backend-lehrer/recording/routes.py b/backend-lehrer/recording/routes.py new file mode 100644 index 0000000..f88b9f9 --- /dev/null +++ b/backend-lehrer/recording/routes.py @@ -0,0 +1,307 @@ +""" +Recording API - Core Recording Routes. + +Webhook, CRUD, health, audit, and download endpoints. +""" + +import uuid +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import JSONResponse + +from .models import ( + JibriWebhookPayload, + RecordingResponse, + RecordingListResponse, + MINIO_ENDPOINT, + MINIO_BUCKET, + DEFAULT_RETENTION_DAYS, +) +from .helpers import ( + _recordings_store, + _transcriptions_store, + _audit_log, + log_audit, +) + +router = APIRouter(tags=["Recordings"]) + + +# ========================================== +# WEBHOOK ENDPOINT (Jibri) +# ========================================== + +@router.post("/webhook") +async def jibri_webhook(payload: JibriWebhookPayload, request: Request): + """ + Webhook endpoint called by Jibri finalize.sh after upload. + + This creates a new recording entry and optionally triggers transcription. + """ + if payload.event != "recording_completed": + return JSONResponse( + status_code=400, + content={"error": f"Unknown event type: {payload.event}"} + ) + + # Extract meeting_id from recording_name (format: meetingId_timestamp) + parts = payload.recording_name.split("_") + meeting_id = parts[0] if parts else payload.recording_name + + # Create recording entry + recording_id = str(uuid.uuid4()) + recorded_at = datetime.utcnow() + + recording = { + "id": recording_id, + "meeting_id": meeting_id, + "jibri_session_id": payload.recording_name, + "title": f"Recording {meeting_id}", + "storage_path": payload.storage_path, + "audio_path": payload.audio_path, + "file_size_bytes": payload.file_size_bytes, + "duration_seconds": None, # Will be updated after analysis + "participant_count": 0, + "status": "uploaded", + "recorded_at": recorded_at.isoformat(), + "retention_days": DEFAULT_RETENTION_DAYS, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat() + } + + _recordings_store[recording_id] = recording + + # Log the creation + log_audit( + action="created", + recording_id=recording_id, + metadata={ + "source": "jibri_webhook", + "storage_path": payload.storage_path, + "file_size_bytes": payload.file_size_bytes + } + ) + + return { + "success": True, + "recording_id": recording_id, + "meeting_id": meeting_id, + "status": "uploaded", + "message": "Recording registered successfully" + } + + +# ========================================== +# HEALTH & AUDIT ENDPOINTS (must be before parameterized routes) +# ========================================== + +@router.get("/health") +async def recordings_health(): + """Health check for recording service.""" + return { + "status": "healthy", + "recordings_count": len(_recordings_store), + "transcriptions_count": len(_transcriptions_store), + "minio_endpoint": MINIO_ENDPOINT, + "bucket": MINIO_BUCKET + } + + +@router.get("/audit/log") +async def get_audit_log( + recording_id: Optional[str] = Query(None), + action: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=1000) +): + """ + Get audit log entries (DSGVO compliance). + + Admin-only endpoint for reviewing recording access history. + """ + logs = _audit_log.copy() + + if recording_id: + logs = [l for l in logs if l.get("recording_id") == recording_id] + if action: + logs = [l for l in logs if l.get("action") == action] + + # Sort by created_at descending + logs.sort(key=lambda x: x["created_at"], reverse=True) + + return { + "entries": logs[:limit], + "total": len(logs) + } + + +# ========================================== +# RECORDING MANAGEMENT ENDPOINTS +# ========================================== + +@router.get("/", response_model=RecordingListResponse) +async def list_recordings( + status: Optional[str] = Query(None, description="Filter by status"), + meeting_id: Optional[str] = Query(None, description="Filter by meeting ID"), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(20, ge=1, le=100, description="Items per page") +): + """ + List all recordings with optional filtering. + + Supports pagination and filtering by status or meeting ID. + """ + # Filter recordings + recordings = list(_recordings_store.values()) + + if status: + recordings = [r for r in recordings if r["status"] == status] + if meeting_id: + recordings = [r for r in recordings if r["meeting_id"] == meeting_id] + + # Sort by recorded_at descending + recordings.sort(key=lambda x: x["recorded_at"], reverse=True) + + # Paginate + total = len(recordings) + start = (page - 1) * page_size + end = start + page_size + page_recordings = recordings[start:end] + + # Convert to response format + result = [] + for rec in page_recordings: + recorded_at = datetime.fromisoformat(rec["recorded_at"]) + retention_expires = recorded_at + timedelta(days=rec["retention_days"]) + + # Check for transcription + trans = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == rec["id"]), + None + ) + + result.append(RecordingResponse( + id=rec["id"], + meeting_id=rec["meeting_id"], + title=rec.get("title"), + storage_path=rec["storage_path"], + audio_path=rec.get("audio_path"), + file_size_bytes=rec.get("file_size_bytes"), + duration_seconds=rec.get("duration_seconds"), + participant_count=rec.get("participant_count", 0), + status=rec["status"], + recorded_at=recorded_at, + retention_days=rec["retention_days"], + retention_expires_at=retention_expires, + transcription_status=trans["status"] if trans else None, + transcription_id=trans["id"] if trans else None + )) + + return RecordingListResponse( + recordings=result, + total=total, + page=page, + page_size=page_size + ) + + +@router.get("/{recording_id}", response_model=RecordingResponse) +async def get_recording(recording_id: str): + """ + Get details for a specific recording. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + # Log view action + log_audit(action="viewed", recording_id=recording_id) + + recorded_at = datetime.fromisoformat(recording["recorded_at"]) + retention_expires = recorded_at + timedelta(days=recording["retention_days"]) + + # Check for transcription + trans = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + + return RecordingResponse( + id=recording["id"], + meeting_id=recording["meeting_id"], + title=recording.get("title"), + storage_path=recording["storage_path"], + audio_path=recording.get("audio_path"), + file_size_bytes=recording.get("file_size_bytes"), + duration_seconds=recording.get("duration_seconds"), + participant_count=recording.get("participant_count", 0), + status=recording["status"], + recorded_at=recorded_at, + retention_days=recording["retention_days"], + retention_expires_at=retention_expires, + transcription_status=trans["status"] if trans else None, + transcription_id=trans["id"] if trans else None + ) + + +@router.delete("/{recording_id}") +async def delete_recording( + recording_id: str, + reason: str = Query(..., description="Reason for deletion (DSGVO audit)") +): + """ + Soft-delete a recording (DSGVO compliance). + + The recording is marked as deleted but retained for audit purposes. + Actual file deletion happens after the audit retention period. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + # Soft delete + recording["status"] = "deleted" + recording["deleted_at"] = datetime.utcnow().isoformat() + recording["updated_at"] = datetime.utcnow().isoformat() + + # Log deletion with reason + log_audit( + action="deleted", + recording_id=recording_id, + metadata={"reason": reason} + ) + + return { + "success": True, + "recording_id": recording_id, + "status": "deleted", + "message": "Recording marked for deletion" + } + + +@router.get("/{recording_id}/download") +async def download_recording(recording_id: str): + """ + Download the recording file. + + In production, this would generate a presigned URL to MinIO. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + if recording["status"] == "deleted": + raise HTTPException(status_code=410, detail="Recording has been deleted") + + # Log download action + log_audit(action="downloaded", recording_id=recording_id) + + # In production, generate presigned URL to MinIO + # For now, return info about where the file is + return { + "recording_id": recording_id, + "storage_path": recording["storage_path"], + "file_size_bytes": recording.get("file_size_bytes"), + "message": "In production, this would redirect to a presigned MinIO URL" + } diff --git a/backend-lehrer/recording/transcription.py b/backend-lehrer/recording/transcription.py new file mode 100644 index 0000000..936cad1 --- /dev/null +++ b/backend-lehrer/recording/transcription.py @@ -0,0 +1,250 @@ +""" +Recording API - Transcription Routes. + +Start transcription, get status, download VTT/SRT subtitle files. +""" + +import uuid +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException +from fastapi.responses import PlainTextResponse + +from .models import ( + TranscriptionRequest, + TranscriptionStatusResponse, +) +from .helpers import ( + _recordings_store, + _transcriptions_store, + log_audit, + format_vtt_time, + format_srt_time, +) + +router = APIRouter(tags=["Recordings"]) + + +# ========================================== +# TRANSCRIPTION ENDPOINTS +# ========================================== + +@router.post("/{recording_id}/transcribe", response_model=TranscriptionStatusResponse) +async def start_transcription(recording_id: str, request: TranscriptionRequest): + """ + Start transcription for a recording. + + Queues the recording for processing by the transcription worker. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + if recording["status"] == "deleted": + raise HTTPException(status_code=400, detail="Cannot transcribe deleted recording") + + # Check if transcription already exists + existing = next( + (t for t in _transcriptions_store.values() + if t["recording_id"] == recording_id and t["status"] != "failed"), + None + ) + if existing: + raise HTTPException( + status_code=409, + detail=f"Transcription already exists with status: {existing['status']}" + ) + + # Create transcription entry + transcription_id = str(uuid.uuid4()) + now = datetime.utcnow() + + transcription = { + "id": transcription_id, + "recording_id": recording_id, + "language": request.language, + "model": request.model, + "status": "pending", + "full_text": None, + "word_count": None, + "confidence_score": None, + "vtt_path": None, + "srt_path": None, + "json_path": None, + "error_message": None, + "processing_started_at": None, + "processing_completed_at": None, + "processing_duration_seconds": None, + "created_at": now.isoformat(), + "updated_at": now.isoformat() + } + + _transcriptions_store[transcription_id] = transcription + + # Update recording status + recording["status"] = "processing" + recording["updated_at"] = now.isoformat() + + # Log transcription start + log_audit( + action="transcription_started", + recording_id=recording_id, + transcription_id=transcription_id, + metadata={"language": request.language, "model": request.model} + ) + + # TODO: Queue job to Redis/Valkey for transcription worker + + return TranscriptionStatusResponse( + id=transcription_id, + recording_id=recording_id, + status="pending", + language=request.language, + model=request.model, + word_count=None, + confidence_score=None, + processing_duration_seconds=None, + error_message=None, + created_at=now, + completed_at=None + ) + + +@router.get("/{recording_id}/transcription", response_model=TranscriptionStatusResponse) +async def get_transcription_status(recording_id: str): + """ + Get transcription status for a recording. + """ + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + return TranscriptionStatusResponse( + id=transcription["id"], + recording_id=transcription["recording_id"], + status=transcription["status"], + language=transcription["language"], + model=transcription["model"], + word_count=transcription.get("word_count"), + confidence_score=transcription.get("confidence_score"), + processing_duration_seconds=transcription.get("processing_duration_seconds"), + error_message=transcription.get("error_message"), + created_at=datetime.fromisoformat(transcription["created_at"]), + completed_at=( + datetime.fromisoformat(transcription["processing_completed_at"]) + if transcription.get("processing_completed_at") else None + ) + ) + + +@router.get("/{recording_id}/transcription/text") +async def get_transcription_text(recording_id: str): + """ + Get the full transcription text. + """ + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + return { + "transcription_id": transcription["id"], + "recording_id": recording_id, + "language": transcription["language"], + "text": transcription.get("full_text", ""), + "word_count": transcription.get("word_count", 0) + } + + +@router.get("/{recording_id}/transcription/vtt") +async def get_transcription_vtt(recording_id: str): + """ + Download transcription as WebVTT subtitle file. + """ + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + # Generate VTT content + vtt_content = "WEBVTT\n\n" + text = transcription.get("full_text", "") + + if text: + sentences = text.replace(".", ".\n").split("\n") + time_offset = 0 + for sentence in sentences: + sentence = sentence.strip() + if sentence: + start = format_vtt_time(time_offset) + time_offset += 3000 + end = format_vtt_time(time_offset) + vtt_content += f"{start} --> {end}\n{sentence}\n\n" + + return PlainTextResponse( + content=vtt_content, + media_type="text/vtt", + headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.vtt"} + ) + + +@router.get("/{recording_id}/transcription/srt") +async def get_transcription_srt(recording_id: str): + """ + Download transcription as SRT subtitle file. + """ + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + # Generate SRT content + srt_content = "" + text = transcription.get("full_text", "") + + if text: + sentences = text.replace(".", ".\n").split("\n") + time_offset = 0 + index = 1 + for sentence in sentences: + sentence = sentence.strip() + if sentence: + start = format_srt_time(time_offset) + time_offset += 3000 + end = format_srt_time(time_offset) + srt_content += f"{index}\n{start} --> {end}\n{sentence}\n\n" + index += 1 + + return PlainTextResponse( + content=srt_content, + media_type="text/plain", + headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.srt"} + ) diff --git a/backend-lehrer/recording_api.py b/backend-lehrer/recording_api.py index cbaf3ed..ded9c45 100644 --- a/backend-lehrer/recording_api.py +++ b/backend-lehrer/recording_api.py @@ -1,22 +1,4 @@ -""" -BreakPilot Recording API — Barrel Re-export. - -Verwaltet Jibri Meeting-Aufzeichnungen und deren Metadaten. -Split into: - - recording_models.py: Pydantic models & config - - recording_helpers.py: In-memory storage & utilities - - recording_routes.py: Core recording CRUD routes - - recording_transcription.py: Transcription routes - - recording_minutes.py: Meeting minutes routes -""" - -from fastapi import APIRouter - -from recording_routes import router as _routes_router -from recording_transcription import router as _transcription_router -from recording_minutes import router as _minutes_router - -router = APIRouter(prefix="/api/recordings", tags=["Recordings"]) -router.include_router(_routes_router) -router.include_router(_transcription_router) -router.include_router(_minutes_router) +# Backward-compat shim -- module moved to recording/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("recording.api") diff --git a/backend-lehrer/recording_helpers.py b/backend-lehrer/recording_helpers.py index 116add6..45b6b9d 100644 --- a/backend-lehrer/recording_helpers.py +++ b/backend-lehrer/recording_helpers.py @@ -1,57 +1,4 @@ -""" -Recording API - In-Memory Storage & Helpers. - -Shared state and utility functions for recording endpoints. -""" - -import uuid -from datetime import datetime -from typing import Optional - - -# ========================================== -# IN-MEMORY STORAGE (Dev Mode) -# ========================================== -# In production, these would be database queries - -_recordings_store: dict = {} -_transcriptions_store: dict = {} -_audit_log: list = [] -_minutes_store: dict = {} - - -def log_audit( - action: str, - recording_id: Optional[str] = None, - transcription_id: Optional[str] = None, - user_id: Optional[str] = None, - metadata: Optional[dict] = None -): - """Log audit event for DSGVO compliance.""" - _audit_log.append({ - "id": str(uuid.uuid4()), - "recording_id": recording_id, - "transcription_id": transcription_id, - "user_id": user_id, - "action": action, - "metadata": metadata or {}, - "created_at": datetime.utcnow().isoformat() - }) - - -def format_vtt_time(ms: int) -> str: - """Format milliseconds to VTT timestamp (HH:MM:SS.mmm).""" - hours = ms // 3600000 - minutes = (ms % 3600000) // 60000 - seconds = (ms % 60000) // 1000 - millis = ms % 1000 - return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}" - - -def format_srt_time(ms: int) -> str: - """Format milliseconds to SRT timestamp (HH:MM:SS,mmm).""" - hours = ms // 3600000 - minutes = (ms % 3600000) // 60000 - seconds = (ms % 60000) // 1000 - millis = ms % 1000 - return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}" +# Backward-compat shim -- module moved to recording/helpers.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("recording.helpers") diff --git a/backend-lehrer/recording_minutes.py b/backend-lehrer/recording_minutes.py index 3cb6d97..00a16e3 100644 --- a/backend-lehrer/recording_minutes.py +++ b/backend-lehrer/recording_minutes.py @@ -1,187 +1,4 @@ -""" -Recording API - Meeting Minutes Routes. - -Generate, retrieve, and export KI-based meeting minutes. -""" - -from datetime import datetime -from typing import Optional - -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import PlainTextResponse, HTMLResponse - -from recording_helpers import ( - _recordings_store, - _transcriptions_store, - _minutes_store, - log_audit, -) - -router = APIRouter(tags=["Recordings"]) - - -# ========================================== -# MEETING MINUTES ENDPOINTS -# ========================================== - -@router.post("/{recording_id}/minutes") -async def generate_meeting_minutes( - recording_id: str, - title: Optional[str] = Query(None, description="Meeting-Titel"), - model: str = Query("breakpilot-teacher-8b", description="LLM Modell") -): - """ - Generiert KI-basierte Meeting Minutes aus der Transkription. - - Nutzt das LLM Gateway (Ollama/vLLM) fuer lokale Verarbeitung. - """ - from meeting_minutes_generator import get_minutes_generator, MeetingMinutes - - # Check recording exists - recording = _recordings_store.get(recording_id) - if not recording: - raise HTTPException(status_code=404, detail="Recording not found") - - # Check transcription exists and is completed - transcription = next( - (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), - None - ) - if not transcription: - raise HTTPException(status_code=400, detail="No transcription found. Please transcribe first.") - - if transcription["status"] != "completed": - raise HTTPException( - status_code=400, - detail=f"Transcription not ready. Status: {transcription['status']}" - ) - - # Check if minutes already exist - existing = _minutes_store.get(recording_id) - if existing and existing.get("status") == "completed": - # Return existing minutes - return existing - - # Get transcript text - transcript_text = transcription.get("full_text", "") - if not transcript_text: - raise HTTPException(status_code=400, detail="Transcription has no text content") - - # Generate meeting minutes - generator = get_minutes_generator() - - try: - minutes = await generator.generate( - transcript=transcript_text, - recording_id=recording_id, - transcription_id=transcription["id"], - title=title, - date=recording.get("recorded_at", "")[:10] if recording.get("recorded_at") else None, - duration_minutes=recording.get("duration_seconds", 0) // 60 if recording.get("duration_seconds") else None, - participant_count=recording.get("participant_count", 0), - model=model - ) - - # Store minutes - minutes_dict = minutes.model_dump() - minutes_dict["generated_at"] = minutes.generated_at.isoformat() - _minutes_store[recording_id] = minutes_dict - - # Log action - log_audit( - action="minutes_generated", - recording_id=recording_id, - metadata={"model": model, "generation_time": minutes.generation_time_seconds} - ) - - return minutes_dict - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Minutes generation failed: {str(e)}") - - -@router.get("/{recording_id}/minutes") -async def get_meeting_minutes(recording_id: str): - """ - Ruft generierte Meeting Minutes ab. - """ - minutes = _minutes_store.get(recording_id) - if not minutes: - raise HTTPException(status_code=404, detail="No meeting minutes found. Generate them first with POST.") - - return minutes - - -def _load_minutes(recording_id: str): - """Load and convert stored minutes dict back to MeetingMinutes.""" - from meeting_minutes_generator import MeetingMinutes - - minutes_dict = _minutes_store.get(recording_id) - if not minutes_dict: - raise HTTPException(status_code=404, detail="No meeting minutes found") - - minutes_dict_copy = minutes_dict.copy() - if isinstance(minutes_dict_copy.get("generated_at"), str): - minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) - - return MeetingMinutes(**minutes_dict_copy) - - -@router.get("/{recording_id}/minutes/markdown") -async def get_minutes_markdown(recording_id: str): - """ - Exportiert Meeting Minutes als Markdown. - """ - from meeting_minutes_generator import minutes_to_markdown - - minutes = _load_minutes(recording_id) - markdown = minutes_to_markdown(minutes) - - return PlainTextResponse( - content=markdown, - media_type="text/markdown", - headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.md"} - ) - - -@router.get("/{recording_id}/minutes/html") -async def get_minutes_html(recording_id: str): - """ - Exportiert Meeting Minutes als HTML. - """ - from meeting_minutes_generator import minutes_to_html - - minutes = _load_minutes(recording_id) - html = minutes_to_html(minutes) - - return HTMLResponse(content=html) - - -@router.get("/{recording_id}/minutes/pdf") -async def get_minutes_pdf(recording_id: str): - """ - Exportiert Meeting Minutes als PDF. - - Benoetigt WeasyPrint (pip install weasyprint). - """ - from meeting_minutes_generator import minutes_to_html - - minutes = _load_minutes(recording_id) - html = minutes_to_html(minutes) - - try: - from weasyprint import HTML - from fastapi.responses import Response - - pdf_bytes = HTML(string=html).write_pdf() - - return Response( - content=pdf_bytes, - media_type="application/pdf", - headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.pdf"} - ) - except ImportError: - raise HTTPException( - status_code=501, - detail="PDF export not available. Install weasyprint: pip install weasyprint" - ) +# Backward-compat shim -- module moved to recording/minutes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("recording.minutes") diff --git a/backend-lehrer/recording_models.py b/backend-lehrer/recording_models.py index 4275ae8..a6cebf5 100644 --- a/backend-lehrer/recording_models.py +++ b/backend-lehrer/recording_models.py @@ -1,98 +1,4 @@ -""" -Recording API - Pydantic Models & Configuration. - -Data models for recording, transcription, and webhook endpoints. -""" - -import os -from datetime import datetime -from typing import Optional, List - -from pydantic import BaseModel, Field - - -# ========================================== -# ENVIRONMENT CONFIGURATION -# ========================================== - -MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") -MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot") -MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123") -MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-recordings") -MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" - -# Default retention period in days (DSGVO compliance) -DEFAULT_RETENTION_DAYS = int(os.getenv("RECORDING_RETENTION_DAYS", "365")) - - -# ========================================== -# PYDANTIC MODELS -# ========================================== - -class JibriWebhookPayload(BaseModel): - """Webhook payload from Jibri finalize.sh script.""" - event: str = Field(..., description="Event type: recording_completed") - recording_name: str = Field(..., description="Unique recording identifier") - storage_path: str = Field(..., description="Path in MinIO bucket") - audio_path: Optional[str] = Field(None, description="Extracted audio path") - file_size_bytes: int = Field(..., description="Video file size in bytes") - timestamp: str = Field(..., description="ISO timestamp of upload") - - -class RecordingCreate(BaseModel): - """Manual recording creation (for testing).""" - meeting_id: str - title: Optional[str] = None - storage_path: str - audio_path: Optional[str] = None - duration_seconds: Optional[int] = None - participant_count: Optional[int] = 0 - retention_days: Optional[int] = DEFAULT_RETENTION_DAYS - - -class RecordingResponse(BaseModel): - """Recording details response.""" - id: str - meeting_id: str - title: Optional[str] - storage_path: str - audio_path: Optional[str] - file_size_bytes: Optional[int] - duration_seconds: Optional[int] - participant_count: int - status: str - recorded_at: datetime - retention_days: int - retention_expires_at: datetime - transcription_status: Optional[str] = None - transcription_id: Optional[str] = None - - -class RecordingListResponse(BaseModel): - """Paginated list of recordings.""" - recordings: List[RecordingResponse] - total: int - page: int - page_size: int - - -class TranscriptionRequest(BaseModel): - """Request to start transcription.""" - language: str = Field(default="de", description="Language code: de, en, etc.") - model: str = Field(default="large-v3", description="Whisper model to use") - priority: int = Field(default=0, description="Queue priority (higher = sooner)") - - -class TranscriptionStatusResponse(BaseModel): - """Transcription status and progress.""" - id: str - recording_id: str - status: str - language: str - model: str - word_count: Optional[int] - confidence_score: Optional[float] - processing_duration_seconds: Optional[int] - error_message: Optional[str] - created_at: datetime - completed_at: Optional[datetime] +# Backward-compat shim -- module moved to recording/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("recording.models") diff --git a/backend-lehrer/recording_routes.py b/backend-lehrer/recording_routes.py index d4f058b..c37212e 100644 --- a/backend-lehrer/recording_routes.py +++ b/backend-lehrer/recording_routes.py @@ -1,307 +1,4 @@ -""" -Recording API - Core Recording Routes. - -Webhook, CRUD, health, audit, and download endpoints. -""" - -import uuid -from datetime import datetime, timedelta -from typing import Optional - -from fastapi import APIRouter, HTTPException, Query, Request -from fastapi.responses import JSONResponse - -from recording_models import ( - JibriWebhookPayload, - RecordingResponse, - RecordingListResponse, - MINIO_ENDPOINT, - MINIO_BUCKET, - DEFAULT_RETENTION_DAYS, -) -from recording_helpers import ( - _recordings_store, - _transcriptions_store, - _audit_log, - log_audit, -) - -router = APIRouter(tags=["Recordings"]) - - -# ========================================== -# WEBHOOK ENDPOINT (Jibri) -# ========================================== - -@router.post("/webhook") -async def jibri_webhook(payload: JibriWebhookPayload, request: Request): - """ - Webhook endpoint called by Jibri finalize.sh after upload. - - This creates a new recording entry and optionally triggers transcription. - """ - if payload.event != "recording_completed": - return JSONResponse( - status_code=400, - content={"error": f"Unknown event type: {payload.event}"} - ) - - # Extract meeting_id from recording_name (format: meetingId_timestamp) - parts = payload.recording_name.split("_") - meeting_id = parts[0] if parts else payload.recording_name - - # Create recording entry - recording_id = str(uuid.uuid4()) - recorded_at = datetime.utcnow() - - recording = { - "id": recording_id, - "meeting_id": meeting_id, - "jibri_session_id": payload.recording_name, - "title": f"Recording {meeting_id}", - "storage_path": payload.storage_path, - "audio_path": payload.audio_path, - "file_size_bytes": payload.file_size_bytes, - "duration_seconds": None, # Will be updated after analysis - "participant_count": 0, - "status": "uploaded", - "recorded_at": recorded_at.isoformat(), - "retention_days": DEFAULT_RETENTION_DAYS, - "created_at": datetime.utcnow().isoformat(), - "updated_at": datetime.utcnow().isoformat() - } - - _recordings_store[recording_id] = recording - - # Log the creation - log_audit( - action="created", - recording_id=recording_id, - metadata={ - "source": "jibri_webhook", - "storage_path": payload.storage_path, - "file_size_bytes": payload.file_size_bytes - } - ) - - return { - "success": True, - "recording_id": recording_id, - "meeting_id": meeting_id, - "status": "uploaded", - "message": "Recording registered successfully" - } - - -# ========================================== -# HEALTH & AUDIT ENDPOINTS (must be before parameterized routes) -# ========================================== - -@router.get("/health") -async def recordings_health(): - """Health check for recording service.""" - return { - "status": "healthy", - "recordings_count": len(_recordings_store), - "transcriptions_count": len(_transcriptions_store), - "minio_endpoint": MINIO_ENDPOINT, - "bucket": MINIO_BUCKET - } - - -@router.get("/audit/log") -async def get_audit_log( - recording_id: Optional[str] = Query(None), - action: Optional[str] = Query(None), - limit: int = Query(100, ge=1, le=1000) -): - """ - Get audit log entries (DSGVO compliance). - - Admin-only endpoint for reviewing recording access history. - """ - logs = _audit_log.copy() - - if recording_id: - logs = [l for l in logs if l.get("recording_id") == recording_id] - if action: - logs = [l for l in logs if l.get("action") == action] - - # Sort by created_at descending - logs.sort(key=lambda x: x["created_at"], reverse=True) - - return { - "entries": logs[:limit], - "total": len(logs) - } - - -# ========================================== -# RECORDING MANAGEMENT ENDPOINTS -# ========================================== - -@router.get("/", response_model=RecordingListResponse) -async def list_recordings( - status: Optional[str] = Query(None, description="Filter by status"), - meeting_id: Optional[str] = Query(None, description="Filter by meeting ID"), - page: int = Query(1, ge=1, description="Page number"), - page_size: int = Query(20, ge=1, le=100, description="Items per page") -): - """ - List all recordings with optional filtering. - - Supports pagination and filtering by status or meeting ID. - """ - # Filter recordings - recordings = list(_recordings_store.values()) - - if status: - recordings = [r for r in recordings if r["status"] == status] - if meeting_id: - recordings = [r for r in recordings if r["meeting_id"] == meeting_id] - - # Sort by recorded_at descending - recordings.sort(key=lambda x: x["recorded_at"], reverse=True) - - # Paginate - total = len(recordings) - start = (page - 1) * page_size - end = start + page_size - page_recordings = recordings[start:end] - - # Convert to response format - result = [] - for rec in page_recordings: - recorded_at = datetime.fromisoformat(rec["recorded_at"]) - retention_expires = recorded_at + timedelta(days=rec["retention_days"]) - - # Check for transcription - trans = next( - (t for t in _transcriptions_store.values() if t["recording_id"] == rec["id"]), - None - ) - - result.append(RecordingResponse( - id=rec["id"], - meeting_id=rec["meeting_id"], - title=rec.get("title"), - storage_path=rec["storage_path"], - audio_path=rec.get("audio_path"), - file_size_bytes=rec.get("file_size_bytes"), - duration_seconds=rec.get("duration_seconds"), - participant_count=rec.get("participant_count", 0), - status=rec["status"], - recorded_at=recorded_at, - retention_days=rec["retention_days"], - retention_expires_at=retention_expires, - transcription_status=trans["status"] if trans else None, - transcription_id=trans["id"] if trans else None - )) - - return RecordingListResponse( - recordings=result, - total=total, - page=page, - page_size=page_size - ) - - -@router.get("/{recording_id}", response_model=RecordingResponse) -async def get_recording(recording_id: str): - """ - Get details for a specific recording. - """ - recording = _recordings_store.get(recording_id) - if not recording: - raise HTTPException(status_code=404, detail="Recording not found") - - # Log view action - log_audit(action="viewed", recording_id=recording_id) - - recorded_at = datetime.fromisoformat(recording["recorded_at"]) - retention_expires = recorded_at + timedelta(days=recording["retention_days"]) - - # Check for transcription - trans = next( - (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), - None - ) - - return RecordingResponse( - id=recording["id"], - meeting_id=recording["meeting_id"], - title=recording.get("title"), - storage_path=recording["storage_path"], - audio_path=recording.get("audio_path"), - file_size_bytes=recording.get("file_size_bytes"), - duration_seconds=recording.get("duration_seconds"), - participant_count=recording.get("participant_count", 0), - status=recording["status"], - recorded_at=recorded_at, - retention_days=recording["retention_days"], - retention_expires_at=retention_expires, - transcription_status=trans["status"] if trans else None, - transcription_id=trans["id"] if trans else None - ) - - -@router.delete("/{recording_id}") -async def delete_recording( - recording_id: str, - reason: str = Query(..., description="Reason for deletion (DSGVO audit)") -): - """ - Soft-delete a recording (DSGVO compliance). - - The recording is marked as deleted but retained for audit purposes. - Actual file deletion happens after the audit retention period. - """ - recording = _recordings_store.get(recording_id) - if not recording: - raise HTTPException(status_code=404, detail="Recording not found") - - # Soft delete - recording["status"] = "deleted" - recording["deleted_at"] = datetime.utcnow().isoformat() - recording["updated_at"] = datetime.utcnow().isoformat() - - # Log deletion with reason - log_audit( - action="deleted", - recording_id=recording_id, - metadata={"reason": reason} - ) - - return { - "success": True, - "recording_id": recording_id, - "status": "deleted", - "message": "Recording marked for deletion" - } - - -@router.get("/{recording_id}/download") -async def download_recording(recording_id: str): - """ - Download the recording file. - - In production, this would generate a presigned URL to MinIO. - """ - recording = _recordings_store.get(recording_id) - if not recording: - raise HTTPException(status_code=404, detail="Recording not found") - - if recording["status"] == "deleted": - raise HTTPException(status_code=410, detail="Recording has been deleted") - - # Log download action - log_audit(action="downloaded", recording_id=recording_id) - - # In production, generate presigned URL to MinIO - # For now, return info about where the file is - return { - "recording_id": recording_id, - "storage_path": recording["storage_path"], - "file_size_bytes": recording.get("file_size_bytes"), - "message": "In production, this would redirect to a presigned MinIO URL" - } +# Backward-compat shim -- module moved to recording/routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("recording.routes") diff --git a/backend-lehrer/recording_transcription.py b/backend-lehrer/recording_transcription.py index 0f8e84c..6bc9308 100644 --- a/backend-lehrer/recording_transcription.py +++ b/backend-lehrer/recording_transcription.py @@ -1,250 +1,4 @@ -""" -Recording API - Transcription Routes. - -Start transcription, get status, download VTT/SRT subtitle files. -""" - -import uuid -from datetime import datetime -from typing import Optional - -from fastapi import APIRouter, HTTPException -from fastapi.responses import PlainTextResponse - -from recording_models import ( - TranscriptionRequest, - TranscriptionStatusResponse, -) -from recording_helpers import ( - _recordings_store, - _transcriptions_store, - log_audit, - format_vtt_time, - format_srt_time, -) - -router = APIRouter(tags=["Recordings"]) - - -# ========================================== -# TRANSCRIPTION ENDPOINTS -# ========================================== - -@router.post("/{recording_id}/transcribe", response_model=TranscriptionStatusResponse) -async def start_transcription(recording_id: str, request: TranscriptionRequest): - """ - Start transcription for a recording. - - Queues the recording for processing by the transcription worker. - """ - recording = _recordings_store.get(recording_id) - if not recording: - raise HTTPException(status_code=404, detail="Recording not found") - - if recording["status"] == "deleted": - raise HTTPException(status_code=400, detail="Cannot transcribe deleted recording") - - # Check if transcription already exists - existing = next( - (t for t in _transcriptions_store.values() - if t["recording_id"] == recording_id and t["status"] != "failed"), - None - ) - if existing: - raise HTTPException( - status_code=409, - detail=f"Transcription already exists with status: {existing['status']}" - ) - - # Create transcription entry - transcription_id = str(uuid.uuid4()) - now = datetime.utcnow() - - transcription = { - "id": transcription_id, - "recording_id": recording_id, - "language": request.language, - "model": request.model, - "status": "pending", - "full_text": None, - "word_count": None, - "confidence_score": None, - "vtt_path": None, - "srt_path": None, - "json_path": None, - "error_message": None, - "processing_started_at": None, - "processing_completed_at": None, - "processing_duration_seconds": None, - "created_at": now.isoformat(), - "updated_at": now.isoformat() - } - - _transcriptions_store[transcription_id] = transcription - - # Update recording status - recording["status"] = "processing" - recording["updated_at"] = now.isoformat() - - # Log transcription start - log_audit( - action="transcription_started", - recording_id=recording_id, - transcription_id=transcription_id, - metadata={"language": request.language, "model": request.model} - ) - - # TODO: Queue job to Redis/Valkey for transcription worker - - return TranscriptionStatusResponse( - id=transcription_id, - recording_id=recording_id, - status="pending", - language=request.language, - model=request.model, - word_count=None, - confidence_score=None, - processing_duration_seconds=None, - error_message=None, - created_at=now, - completed_at=None - ) - - -@router.get("/{recording_id}/transcription", response_model=TranscriptionStatusResponse) -async def get_transcription_status(recording_id: str): - """ - Get transcription status for a recording. - """ - transcription = next( - (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), - None - ) - if not transcription: - raise HTTPException(status_code=404, detail="No transcription found for this recording") - - return TranscriptionStatusResponse( - id=transcription["id"], - recording_id=transcription["recording_id"], - status=transcription["status"], - language=transcription["language"], - model=transcription["model"], - word_count=transcription.get("word_count"), - confidence_score=transcription.get("confidence_score"), - processing_duration_seconds=transcription.get("processing_duration_seconds"), - error_message=transcription.get("error_message"), - created_at=datetime.fromisoformat(transcription["created_at"]), - completed_at=( - datetime.fromisoformat(transcription["processing_completed_at"]) - if transcription.get("processing_completed_at") else None - ) - ) - - -@router.get("/{recording_id}/transcription/text") -async def get_transcription_text(recording_id: str): - """ - Get the full transcription text. - """ - transcription = next( - (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), - None - ) - if not transcription: - raise HTTPException(status_code=404, detail="No transcription found for this recording") - - if transcription["status"] != "completed": - raise HTTPException( - status_code=400, - detail=f"Transcription not ready. Status: {transcription['status']}" - ) - - return { - "transcription_id": transcription["id"], - "recording_id": recording_id, - "language": transcription["language"], - "text": transcription.get("full_text", ""), - "word_count": transcription.get("word_count", 0) - } - - -@router.get("/{recording_id}/transcription/vtt") -async def get_transcription_vtt(recording_id: str): - """ - Download transcription as WebVTT subtitle file. - """ - transcription = next( - (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), - None - ) - if not transcription: - raise HTTPException(status_code=404, detail="No transcription found for this recording") - - if transcription["status"] != "completed": - raise HTTPException( - status_code=400, - detail=f"Transcription not ready. Status: {transcription['status']}" - ) - - # Generate VTT content - vtt_content = "WEBVTT\n\n" - text = transcription.get("full_text", "") - - if text: - sentences = text.replace(".", ".\n").split("\n") - time_offset = 0 - for sentence in sentences: - sentence = sentence.strip() - if sentence: - start = format_vtt_time(time_offset) - time_offset += 3000 - end = format_vtt_time(time_offset) - vtt_content += f"{start} --> {end}\n{sentence}\n\n" - - return PlainTextResponse( - content=vtt_content, - media_type="text/vtt", - headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.vtt"} - ) - - -@router.get("/{recording_id}/transcription/srt") -async def get_transcription_srt(recording_id: str): - """ - Download transcription as SRT subtitle file. - """ - transcription = next( - (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), - None - ) - if not transcription: - raise HTTPException(status_code=404, detail="No transcription found for this recording") - - if transcription["status"] != "completed": - raise HTTPException( - status_code=400, - detail=f"Transcription not ready. Status: {transcription['status']}" - ) - - # Generate SRT content - srt_content = "" - text = transcription.get("full_text", "") - - if text: - sentences = text.replace(".", ".\n").split("\n") - time_offset = 0 - index = 1 - for sentence in sentences: - sentence = sentence.strip() - if sentence: - start = format_srt_time(time_offset) - time_offset += 3000 - end = format_srt_time(time_offset) - srt_content += f"{index}\n{start} --> {end}\n{sentence}\n\n" - index += 1 - - return PlainTextResponse( - content=srt_content, - media_type="text/plain", - headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.srt"} - ) +# Backward-compat shim -- module moved to recording/transcription.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("recording.transcription") diff --git a/backend-lehrer/teacher_dashboard_analytics.py b/backend-lehrer/teacher_dashboard_analytics.py index 4a84bca..8e8a44e 100644 --- a/backend-lehrer/teacher_dashboard_analytics.py +++ b/backend-lehrer/teacher_dashboard_analytics.py @@ -1,267 +1,4 @@ -# ============================================== -# Teacher Dashboard - Analytics & Progress Routes -# ============================================== - -from fastapi import APIRouter, HTTPException, Query, Depends, Request -from typing import List, Optional, Dict, Any -from datetime import datetime, timedelta -import logging - -from teacher_dashboard_models import ( - UnitAssignmentStatus, TeacherControlSettings, - UnitAssignment, StudentUnitProgress, ClassUnitProgress, - MisconceptionReport, ClassAnalyticsSummary, ContentResource, - get_current_teacher, get_teacher_database, - get_classes_for_teacher, get_students_in_class, - REQUIRE_AUTH, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(tags=["Teacher Dashboard"]) - -# Shared in-memory store reference (set from teacher_dashboard_api) -_assignments_store: Dict[str, Dict[str, Any]] = {} - - -def set_assignments_store(store: Dict[str, Dict[str, Any]]): - """Share the in-memory assignments store from the main module.""" - global _assignments_store - _assignments_store = store - - -# ============================================== -# API Endpoints - Progress & Analytics -# ============================================== - -@router.get("/assignments/{assignment_id}/progress", response_model=ClassUnitProgress) -async def get_assignment_progress( - assignment_id: str, - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> ClassUnitProgress: - """Get detailed progress for an assignment.""" - db = await get_teacher_database() - assignment = None - if db: - try: - assignment = await db.get_assignment(assignment_id) - except Exception as e: - logger.error(f"Failed to get assignment: {e}") - if not assignment and assignment_id in _assignments_store: - assignment = _assignments_store[assignment_id] - if not assignment or assignment["teacher_id"] != teacher["user_id"]: - raise HTTPException(status_code=404, detail="Assignment not found") - - students = await get_students_in_class(assignment["class_id"]) - student_progress = [] - total_completion = 0.0 - total_precheck = 0.0 - total_postcheck = 0.0 - total_time = 0 - precheck_count = 0 - postcheck_count = 0 - started = 0 - completed = 0 - - for student in students: - student_id = student.get("id", student.get("student_id")) - progress = StudentUnitProgress( - student_id=student_id, - student_name=student.get("name", f"Student {student_id[:8]}"), - status="not_started", completion_rate=0.0, stops_completed=0, total_stops=0, - ) - if db: - try: - session_data = await db.get_student_unit_session( - student_id=student_id, unit_id=assignment["unit_id"] - ) - if session_data: - progress.session_id = session_data.get("session_id") - progress.status = "completed" if session_data.get("completed_at") else "in_progress" - progress.completion_rate = session_data.get("completion_rate", 0.0) - progress.precheck_score = session_data.get("precheck_score") - progress.postcheck_score = session_data.get("postcheck_score") - progress.time_spent_minutes = session_data.get("duration_seconds", 0) // 60 - progress.last_activity = session_data.get("updated_at") - progress.stops_completed = session_data.get("stops_completed", 0) - progress.total_stops = session_data.get("total_stops", 0) - if progress.precheck_score is not None and progress.postcheck_score is not None: - progress.learning_gain = progress.postcheck_score - progress.precheck_score - total_completion += progress.completion_rate - total_time += progress.time_spent_minutes - if progress.precheck_score is not None: - total_precheck += progress.precheck_score - precheck_count += 1 - if progress.postcheck_score is not None: - total_postcheck += progress.postcheck_score - postcheck_count += 1 - if progress.status != "not_started": - started += 1 - if progress.status == "completed": - completed += 1 - except Exception as e: - logger.error(f"Failed to get student progress: {e}") - student_progress.append(progress) - - total_students = len(students) or 1 - return ClassUnitProgress( - assignment_id=assignment_id, unit_id=assignment["unit_id"], - unit_title=f"Unit {assignment['unit_id']}", class_id=assignment["class_id"], - class_name=f"Class {assignment['class_id'][:8]}", total_students=len(students), - started_count=started, completed_count=completed, - avg_completion_rate=total_completion / total_students, - avg_precheck_score=total_precheck / precheck_count if precheck_count > 0 else None, - avg_postcheck_score=total_postcheck / postcheck_count if postcheck_count > 0 else None, - avg_learning_gain=(total_postcheck / postcheck_count - total_precheck / precheck_count) - if precheck_count > 0 and postcheck_count > 0 else None, - avg_time_minutes=total_time / started if started > 0 else 0, - students=student_progress, - ) - - -@router.get("/classes/{class_id}/analytics", response_model=ClassAnalyticsSummary) -async def get_class_analytics( - class_id: str, - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> ClassAnalyticsSummary: - """Get summary analytics for a class.""" - db = await get_teacher_database() - assignments = [] - if db: - try: - assignments = await db.list_assignments(teacher_id=teacher["user_id"], class_id=class_id) - except Exception as e: - logger.error(f"Failed to list assignments: {e}") - if not assignments: - assignments = [ - a for a in _assignments_store.values() - if a["class_id"] == class_id and a["teacher_id"] == teacher["user_id"] - ] - - total_units = len(assignments) - completed_units = sum(1 for a in assignments if a.get("status") == "completed") - active_units = sum(1 for a in assignments if a.get("status") == "active") - - students = await get_students_in_class(class_id) - student_scores = {} - misconceptions = [] - if db: - try: - for student in students: - student_id = student.get("id", student.get("student_id")) - analytics = await db.get_student_analytics(student_id) - if analytics: - student_scores[student_id] = { - "name": student.get("name", student_id[:8]), - "avg_score": analytics.get("avg_postcheck_score", 0), - "total_time": analytics.get("total_time_minutes", 0), - } - misconceptions_data = await db.get_class_misconceptions(class_id) - for m in misconceptions_data: - misconceptions.append(MisconceptionReport( - concept_id=m["concept_id"], concept_label=m["concept_label"], - misconception=m["misconception"], affected_students=m["affected_students"], - frequency=m["frequency"], unit_id=m["unit_id"], stop_id=m["stop_id"], - )) - except Exception as e: - logger.error(f"Failed to aggregate analytics: {e}") - - sorted_students = sorted(student_scores.items(), key=lambda x: x[1]["avg_score"], reverse=True) - top_performers = [s[1]["name"] for s in sorted_students[:3]] - struggling_students = [s[1]["name"] for s in sorted_students[-3:] if s[1]["avg_score"] < 0.6] - total_time = sum(s["total_time"] for s in student_scores.values()) - avg_scores = [s["avg_score"] for s in student_scores.values() if s["avg_score"] > 0] - avg_completion = sum(avg_scores) / len(avg_scores) if avg_scores else 0 - - return ClassAnalyticsSummary( - class_id=class_id, class_name=f"Klasse {class_id[:8]}", - total_units_assigned=total_units, units_completed=completed_units, - active_units=active_units, avg_completion_rate=avg_completion, - avg_learning_gain=None, total_time_hours=total_time / 60, - top_performers=top_performers, struggling_students=struggling_students, - common_misconceptions=misconceptions[:5], - ) - - -@router.get("/students/{student_id}/progress") -async def get_student_progress( - student_id: str, - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> Dict[str, Any]: - """Get detailed progress for a specific student.""" - db = await get_teacher_database() - if db: - try: - progress = await db.get_student_full_progress(student_id) - return progress - except Exception as e: - logger.error(f"Failed to get student progress: {e}") - return { - "student_id": student_id, "units_attempted": 0, "units_completed": 0, - "avg_score": 0.0, "total_time_minutes": 0, "sessions": [], - } - - -# ============================================== -# API Endpoints - Content Resources -# ============================================== - -@router.get("/assignments/{assignment_id}/resources", response_model=List[ContentResource]) -async def get_assignment_resources( - assignment_id: str, - teacher: Dict[str, Any] = Depends(get_current_teacher), - request: Request = None -) -> List[ContentResource]: - """Get generated content resources for an assignment.""" - db = await get_teacher_database() - assignment = None - if db: - try: - assignment = await db.get_assignment(assignment_id) - except Exception as e: - logger.error(f"Failed to get assignment: {e}") - if not assignment and assignment_id in _assignments_store: - assignment = _assignments_store[assignment_id] - if not assignment or assignment["teacher_id"] != teacher["user_id"]: - raise HTTPException(status_code=404, detail="Assignment not found") - - unit_id = assignment["unit_id"] - base_url = str(request.base_url).rstrip("/") if request else "http://localhost:8000" - return [ - ContentResource(resource_type="h5p", title=f"{unit_id} - H5P Aktivitaeten", - url=f"{base_url}/api/units/content/{unit_id}/h5p", - generated_at=datetime.utcnow(), unit_id=unit_id), - ContentResource(resource_type="worksheet", title=f"{unit_id} - Arbeitsblatt (HTML)", - url=f"{base_url}/api/units/content/{unit_id}/worksheet", - generated_at=datetime.utcnow(), unit_id=unit_id), - ContentResource(resource_type="pdf", title=f"{unit_id} - Arbeitsblatt (PDF)", - url=f"{base_url}/api/units/content/{unit_id}/worksheet.pdf", - generated_at=datetime.utcnow(), unit_id=unit_id), - ] - - -@router.post("/assignments/{assignment_id}/regenerate-content") -async def regenerate_content( - assignment_id: str, - resource_type: str = Query("all", description="h5p, pdf, or all"), - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> Dict[str, Any]: - """Trigger regeneration of content resources.""" - db = await get_teacher_database() - assignment = None - if db: - try: - assignment = await db.get_assignment(assignment_id) - except Exception as e: - logger.error(f"Failed to get assignment: {e}") - if not assignment and assignment_id in _assignments_store: - assignment = _assignments_store[assignment_id] - if not assignment or assignment["teacher_id"] != teacher["user_id"]: - raise HTTPException(status_code=404, detail="Assignment not found") - - logger.info(f"Content regeneration triggered for {assignment['unit_id']}: {resource_type}") - return { - "status": "queued", "assignment_id": assignment_id, - "unit_id": assignment["unit_id"], "resource_type": resource_type, - "message": "Content regeneration has been queued", - } +# Backward-compat shim -- module moved to dashboard/analytics.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("dashboard.analytics") diff --git a/backend-lehrer/teacher_dashboard_api.py b/backend-lehrer/teacher_dashboard_api.py index 0212acf..ce1d6ba 100644 --- a/backend-lehrer/teacher_dashboard_api.py +++ b/backend-lehrer/teacher_dashboard_api.py @@ -1,329 +1,4 @@ -# ============================================== -# Breakpilot Drive - Teacher Dashboard API -# ============================================== -# Lehrer-Dashboard fuer Unit-Zuweisung und Analytics. -# -# Split structure: -# - teacher_dashboard_models.py: Models, Auth, DB/School helpers -# - teacher_dashboard_analytics.py: Progress, analytics, content routes -# - teacher_dashboard_api.py: Assignment CRUD, dashboard, units (this file) - -from fastapi import APIRouter, HTTPException, Query, Depends -from typing import List, Optional, Dict, Any -from datetime import datetime, timedelta -import uuid -import logging - -from teacher_dashboard_models import ( - UnitAssignmentStatus, TeacherControlSettings, AssignUnitRequest, - UnitAssignment, - get_current_teacher, get_teacher_database, - get_classes_for_teacher, - REQUIRE_AUTH, -) -from teacher_dashboard_analytics import ( - router as analytics_router, - set_assignments_store, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/teacher", tags=["Teacher Dashboard"]) - -# In-Memory Storage (Fallback) -_assignments_store: Dict[str, Dict[str, Any]] = {} - -# Share the store with the analytics module and include its routes -set_assignments_store(_assignments_store) -router.include_router(analytics_router) - - -# ============================================== -# API Endpoints - Unit Assignment -# ============================================== - -@router.post("/assignments", response_model=UnitAssignment) -async def assign_unit_to_class( - request_data: AssignUnitRequest, - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> UnitAssignment: - """Assign a unit to a class.""" - assignment_id = str(uuid.uuid4()) - now = datetime.utcnow() - settings = request_data.settings or TeacherControlSettings() - - assignment = { - "assignment_id": assignment_id, "unit_id": request_data.unit_id, - "class_id": request_data.class_id, "teacher_id": teacher["user_id"], - "status": UnitAssignmentStatus.ACTIVE, "settings": settings.model_dump(), - "due_date": request_data.due_date, "notes": request_data.notes, - "created_at": now, "updated_at": now, - } - - db = await get_teacher_database() - if db: - try: - await db.create_assignment(assignment) - except Exception as e: - logger.error(f"Failed to store assignment: {e}") - - _assignments_store[assignment_id] = assignment - logger.info(f"Unit {request_data.unit_id} assigned to class {request_data.class_id}") - - return UnitAssignment( - assignment_id=assignment_id, unit_id=request_data.unit_id, - class_id=request_data.class_id, teacher_id=teacher["user_id"], - status=UnitAssignmentStatus.ACTIVE, settings=settings, - due_date=request_data.due_date, notes=request_data.notes, - created_at=now, updated_at=now, - ) - - -@router.get("/assignments", response_model=List[UnitAssignment]) -async def list_assignments( - class_id: Optional[str] = Query(None, description="Filter by class"), - status: Optional[UnitAssignmentStatus] = Query(None, description="Filter by status"), - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> List[UnitAssignment]: - """List all unit assignments for the teacher.""" - db = await get_teacher_database() - assignments = [] - - if db: - try: - assignments = await db.list_assignments( - teacher_id=teacher["user_id"], - class_id=class_id, - status=status.value if status else None - ) - except Exception as e: - logger.error(f"Failed to list assignments: {e}") - - if not assignments: - for assignment in _assignments_store.values(): - if assignment["teacher_id"] != teacher["user_id"]: - continue - if class_id and assignment["class_id"] != class_id: - continue - if status and assignment["status"] != status.value: - continue - assignments.append(assignment) - - return [ - UnitAssignment( - assignment_id=a["assignment_id"], unit_id=a["unit_id"], - class_id=a["class_id"], teacher_id=a["teacher_id"], - status=a["status"], settings=TeacherControlSettings(**a["settings"]), - due_date=a.get("due_date"), notes=a.get("notes"), - created_at=a["created_at"], updated_at=a["updated_at"], - ) - for a in assignments - ] - - -@router.get("/assignments/{assignment_id}", response_model=UnitAssignment) -async def get_assignment( - assignment_id: str, - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> UnitAssignment: - """Get details of a specific assignment.""" - db = await get_teacher_database() - if db: - try: - assignment = await db.get_assignment(assignment_id) - if assignment and assignment["teacher_id"] == teacher["user_id"]: - return UnitAssignment( - assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"], - class_id=assignment["class_id"], teacher_id=assignment["teacher_id"], - status=assignment["status"], - settings=TeacherControlSettings(**assignment["settings"]), - due_date=assignment.get("due_date"), notes=assignment.get("notes"), - created_at=assignment["created_at"], updated_at=assignment["updated_at"], - ) - except Exception as e: - logger.error(f"Failed to get assignment: {e}") - - if assignment_id in _assignments_store: - a = _assignments_store[assignment_id] - if a["teacher_id"] == teacher["user_id"]: - return UnitAssignment( - assignment_id=a["assignment_id"], unit_id=a["unit_id"], - class_id=a["class_id"], teacher_id=a["teacher_id"], - status=a["status"], settings=TeacherControlSettings(**a["settings"]), - due_date=a.get("due_date"), notes=a.get("notes"), - created_at=a["created_at"], updated_at=a["updated_at"], - ) - - raise HTTPException(status_code=404, detail="Assignment not found") - - -@router.put("/assignments/{assignment_id}") -async def update_assignment( - assignment_id: str, - settings: Optional[TeacherControlSettings] = None, - status: Optional[UnitAssignmentStatus] = None, - due_date: Optional[datetime] = None, - notes: Optional[str] = None, - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> UnitAssignment: - """Update assignment settings or status.""" - db = await get_teacher_database() - assignment = None - - if db: - try: - assignment = await db.get_assignment(assignment_id) - except Exception as e: - logger.error(f"Failed to get assignment: {e}") - - if not assignment and assignment_id in _assignments_store: - assignment = _assignments_store[assignment_id] - - if not assignment or assignment["teacher_id"] != teacher["user_id"]: - raise HTTPException(status_code=404, detail="Assignment not found") - - if settings: - assignment["settings"] = settings.model_dump() - if status: - assignment["status"] = status.value - if due_date: - assignment["due_date"] = due_date - if notes is not None: - assignment["notes"] = notes - assignment["updated_at"] = datetime.utcnow() - - if db: - try: - await db.update_assignment(assignment) - except Exception as e: - logger.error(f"Failed to update assignment: {e}") - - _assignments_store[assignment_id] = assignment - - return UnitAssignment( - assignment_id=assignment["assignment_id"], unit_id=assignment["unit_id"], - class_id=assignment["class_id"], teacher_id=assignment["teacher_id"], - status=assignment["status"], settings=TeacherControlSettings(**assignment["settings"]), - due_date=assignment.get("due_date"), notes=assignment.get("notes"), - created_at=assignment["created_at"], updated_at=assignment["updated_at"], - ) - - -@router.delete("/assignments/{assignment_id}") -async def delete_assignment( - assignment_id: str, - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> Dict[str, str]: - """Delete/archive an assignment.""" - db = await get_teacher_database() - if db: - try: - assignment = await db.get_assignment(assignment_id) - if assignment and assignment["teacher_id"] == teacher["user_id"]: - await db.delete_assignment(assignment_id) - if assignment_id in _assignments_store: - del _assignments_store[assignment_id] - return {"status": "deleted", "assignment_id": assignment_id} - except Exception as e: - logger.error(f"Failed to delete assignment: {e}") - - if assignment_id in _assignments_store: - a = _assignments_store[assignment_id] - if a["teacher_id"] == teacher["user_id"]: - del _assignments_store[assignment_id] - return {"status": "deleted", "assignment_id": assignment_id} - - raise HTTPException(status_code=404, detail="Assignment not found") - - -# ============================================== -# API Endpoints - Available Units -# ============================================== - -@router.get("/units/available") -async def list_available_units( - grade: Optional[str] = Query(None, description="Filter by grade level"), - template: Optional[str] = Query(None, description="Filter by template type"), - locale: str = Query("de-DE", description="Locale"), - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> List[Dict[str, Any]]: - """List all available units for assignment.""" - db = await get_teacher_database() - if db: - try: - units = await db.list_available_units(grade=grade, template=template, locale=locale) - return units - except Exception as e: - logger.error(f"Failed to list units: {e}") - return [ - { - "unit_id": "bio_eye_lightpath_v1", "title": "Auge - Lichtstrahl-Flug", - "template": "flight_path", "grade_band": ["5", "6", "7"], - "duration_minutes": 8, "difficulty": "base", - "description": "Reise durch das Auge und folge dem Lichtstrahl", - "learning_objectives": ["Verstehen des Lichtwegs durch das Auge", - "Funktionen der Augenbestandteile benennen"], - }, - { - "unit_id": "math_pizza_equivalence_v1", - "title": "Pizza-Boxenstopp - Brueche und Prozent", - "template": "station_loop", "grade_band": ["5", "6"], - "duration_minutes": 10, "difficulty": "base", - "description": "Entdecke die Verbindung zwischen Bruechen, Dezimalzahlen und Prozent", - "learning_objectives": ["Brueche in Prozent umrechnen", "Aequivalenzen erkennen"], - }, - ] - - -# ============================================== -# API Endpoints - Dashboard Overview -# ============================================== - -@router.get("/dashboard") -async def get_dashboard( - teacher: Dict[str, Any] = Depends(get_current_teacher) -) -> Dict[str, Any]: - """Get teacher dashboard overview.""" - db = await get_teacher_database() - classes = await get_classes_for_teacher(teacher["user_id"]) - - active_assignments = [] - if db: - try: - active_assignments = await db.list_assignments( - teacher_id=teacher["user_id"], status="active" - ) - except Exception as e: - logger.error(f"Failed to list assignments: {e}") - if not active_assignments: - active_assignments = [ - a for a in _assignments_store.values() - if a["teacher_id"] == teacher["user_id"] and a.get("status") == "active" - ] - - alerts = [] - for assignment in active_assignments: - if assignment.get("due_date") and assignment["due_date"] < datetime.utcnow() + timedelta(days=2): - alerts.append({ - "type": "due_soon", "assignment_id": assignment["assignment_id"], - "message": "Zuweisung endet in weniger als 2 Tagen", - }) - - return { - "teacher": {"id": teacher["user_id"], "name": teacher.get("name", "Lehrer"), - "email": teacher.get("email")}, - "classes": len(classes), "active_assignments": len(active_assignments), - "total_students": sum(c.get("student_count", 0) for c in classes), - "alerts": alerts, "recent_activity": [], - } - - -@router.get("/health") -async def health_check() -> Dict[str, Any]: - """Health check for teacher dashboard API.""" - db = await get_teacher_database() - db_status = "connected" if db else "in-memory" - return { - "status": "healthy", "service": "teacher-dashboard", - "database": db_status, "auth_required": REQUIRE_AUTH, - } +# Backward-compat shim -- module moved to dashboard/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("dashboard.api") diff --git a/backend-lehrer/teacher_dashboard_models.py b/backend-lehrer/teacher_dashboard_models.py index 88e6d9c..3029317 100644 --- a/backend-lehrer/teacher_dashboard_models.py +++ b/backend-lehrer/teacher_dashboard_models.py @@ -1,226 +1,4 @@ -""" -Teacher Dashboard - Pydantic Models, Auth Dependency, and Service Helpers. -""" - -import os -import logging -from datetime import datetime -from typing import List, Optional, Dict, Any -from enum import Enum - -from fastapi import HTTPException, Request -from pydantic import BaseModel -import httpx - -logger = logging.getLogger(__name__) - -# Feature flags -USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" -REQUIRE_AUTH = os.getenv("TEACHER_REQUIRE_AUTH", "true").lower() == "true" -SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") - - -# ============================================== -# Pydantic Models -# ============================================== - -class UnitAssignmentStatus(str, Enum): - """Status of a unit assignment""" - DRAFT = "draft" - ACTIVE = "active" - COMPLETED = "completed" - ARCHIVED = "archived" - - -class TeacherControlSettings(BaseModel): - """Unit settings that teachers can configure""" - allow_skip: bool = True - allow_replay: bool = True - max_time_per_stop_sec: int = 90 - show_hints: bool = True - require_precheck: bool = True - require_postcheck: bool = True - - -class AssignUnitRequest(BaseModel): - """Request to assign a unit to a class""" - unit_id: str - class_id: str - due_date: Optional[datetime] = None - settings: Optional[TeacherControlSettings] = None - notes: Optional[str] = None - - -class UnitAssignment(BaseModel): - """Unit assignment record""" - assignment_id: str - unit_id: str - class_id: str - teacher_id: str - status: UnitAssignmentStatus - settings: TeacherControlSettings - due_date: Optional[datetime] = None - notes: Optional[str] = None - created_at: datetime - updated_at: datetime - - -class StudentUnitProgress(BaseModel): - """Progress of a single student on a unit""" - student_id: str - student_name: str - session_id: Optional[str] = None - status: str # "not_started", "in_progress", "completed" - completion_rate: float = 0.0 - precheck_score: Optional[float] = None - postcheck_score: Optional[float] = None - learning_gain: Optional[float] = None - time_spent_minutes: int = 0 - last_activity: Optional[datetime] = None - current_stop: Optional[str] = None - stops_completed: int = 0 - total_stops: int = 0 - - -class ClassUnitProgress(BaseModel): - """Overall progress of a class on a unit""" - assignment_id: str - unit_id: str - unit_title: str - class_id: str - class_name: str - total_students: int - started_count: int - completed_count: int - avg_completion_rate: float - avg_precheck_score: Optional[float] = None - avg_postcheck_score: Optional[float] = None - avg_learning_gain: Optional[float] = None - avg_time_minutes: float - students: List[StudentUnitProgress] - - -class MisconceptionReport(BaseModel): - """Report of detected misconceptions""" - concept_id: str - concept_label: str - misconception: str - affected_students: List[str] - frequency: int - unit_id: str - stop_id: str - - -class ClassAnalyticsSummary(BaseModel): - """Summary analytics for a class""" - class_id: str - class_name: str - total_units_assigned: int - units_completed: int - active_units: int - avg_completion_rate: float - avg_learning_gain: Optional[float] - total_time_hours: float - top_performers: List[str] - struggling_students: List[str] - common_misconceptions: List[MisconceptionReport] - - -class ContentResource(BaseModel): - """Generated content resource""" - resource_type: str # "h5p", "pdf", "worksheet" - title: str - url: str - generated_at: datetime - unit_id: str - - -# ============================================== -# Auth Dependency -# ============================================== - -async def get_current_teacher(request: Request) -> Dict[str, Any]: - """Get current teacher from JWT token.""" - if not REQUIRE_AUTH: - return { - "user_id": "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20", - "email": "demo@breakpilot.app", - "role": "teacher", - "name": "Demo Lehrer" - } - - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Missing authorization token") - - try: - import jwt - token = auth_header[7:] - secret = os.getenv("JWT_SECRET", "dev-secret-key") - payload = jwt.decode(token, secret, algorithms=["HS256"]) - - if payload.get("role") not in ["teacher", "admin"]: - raise HTTPException(status_code=403, detail="Teacher or admin role required") - - return payload - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token expired") - except jwt.InvalidTokenError: - raise HTTPException(status_code=401, detail="Invalid token") - - -# ============================================== -# Database Integration -# ============================================== - -_teacher_db = None - - -async def get_teacher_database(): - """Get teacher database instance with lazy initialization.""" - global _teacher_db - if not USE_DATABASE: - return None - if _teacher_db is None: - try: - from unit.database import get_teacher_db - _teacher_db = await get_teacher_db() - logger.info("Teacher database initialized") - except ImportError: - logger.warning("Teacher database module not available") - except Exception as e: - logger.warning(f"Teacher database not available: {e}") - return _teacher_db - - -# ============================================== -# School Service Integration -# ============================================== - -async def get_classes_for_teacher(teacher_id: str) -> List[Dict[str, Any]]: - """Get classes assigned to a teacher from school service.""" - async with httpx.AsyncClient(timeout=10.0) as client: - try: - response = await client.get( - f"{SCHOOL_SERVICE_URL}/api/v1/school/classes", - headers={"X-Teacher-ID": teacher_id} - ) - if response.status_code == 200: - return response.json() - except Exception as e: - logger.error(f"Failed to get classes from school service: {e}") - return [] - - -async def get_students_in_class(class_id: str) -> List[Dict[str, Any]]: - """Get students in a class from school service.""" - async with httpx.AsyncClient(timeout=10.0) as client: - try: - response = await client.get( - f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students" - ) - if response.status_code == 200: - return response.json() - except Exception as e: - logger.error(f"Failed to get students from school service: {e}") - return [] +# Backward-compat shim -- module moved to dashboard/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("dashboard.models") diff --git a/backend-lehrer/unit_analytics_api.py b/backend-lehrer/unit_analytics_api.py index 2f9856f..6fbff25 100644 --- a/backend-lehrer/unit_analytics_api.py +++ b/backend-lehrer/unit_analytics_api.py @@ -1,25 +1,4 @@ -""" -Breakpilot Drive - Unit Analytics API — Barrel Re-export. - -Erweiterte Analytics fuer Lernfortschritt: -- Pre/Post Gain Visualisierung -- Misconception-Tracking -- Stop-Level Analytics -- Aggregierte Klassen-Statistiken -- Export-Funktionen - -Split into: - - unit_analytics_models.py: Pydantic models & enums - - unit_analytics_helpers.py: Database access & computation helpers - - unit_analytics_routes.py: Core analytics endpoint handlers - - unit_analytics_export.py: Export & dashboard endpoints -""" - -from fastapi import APIRouter - -from unit_analytics_routes import router as _routes_router -from unit_analytics_export import router as _export_router - -router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"]) -router.include_router(_routes_router) -router.include_router(_export_router) +# Backward-compat shim -- module moved to units/analytics_api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.analytics_api") diff --git a/backend-lehrer/unit_analytics_export.py b/backend-lehrer/unit_analytics_export.py index add5382..44fea2a 100644 --- a/backend-lehrer/unit_analytics_export.py +++ b/backend-lehrer/unit_analytics_export.py @@ -1,145 +1,4 @@ -""" -Unit Analytics API - Export & Dashboard Routes. - -Export endpoints for learning gains and misconceptions, plus dashboard overview. -""" - -import logging -from datetime import datetime -from typing import Optional, Dict, Any - -from fastapi import APIRouter, Query -from fastapi.responses import Response - -from unit_analytics_models import TimeRange, ExportFormat -from unit_analytics_helpers import get_analytics_database - -logger = logging.getLogger(__name__) - -router = APIRouter(tags=["Unit Analytics"]) - - -# ============================================== -# API Endpoints - Export -# ============================================== - -@router.get("/export/learning-gains") -async def export_learning_gains( - unit_id: Optional[str] = Query(None), - class_id: Optional[str] = Query(None), - time_range: TimeRange = Query(TimeRange.ALL), - format: ExportFormat = Query(ExportFormat.JSON), -) -> Any: - """ - Export learning gain data. - """ - db = await get_analytics_database() - data = [] - - if db: - try: - data = await db.export_learning_gains( - unit_id=unit_id, class_id=class_id, time_range=time_range.value - ) - except Exception as e: - logger.error(f"Failed to export data: {e}") - - if format == ExportFormat.CSV: - if not data: - csv_content = "student_id,unit_id,precheck,postcheck,gain\n" - else: - csv_content = "student_id,unit_id,precheck,postcheck,gain\n" - for row in data: - csv_content += f"{row['student_id']},{row['unit_id']},{row.get('precheck', '')},{row.get('postcheck', '')},{row.get('gain', '')}\n" - - return Response( - content=csv_content, - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=learning_gains.csv"} - ) - - return { - "export_date": datetime.utcnow().isoformat(), - "filters": { - "unit_id": unit_id, "class_id": class_id, "time_range": time_range.value, - }, - "data": data, - } - - -@router.get("/export/misconceptions") -async def export_misconceptions( - class_id: Optional[str] = Query(None), - format: ExportFormat = Query(ExportFormat.JSON), -) -> Any: - """ - Export misconception data for further analysis. - """ - # Import here to avoid circular dependency - from unit_analytics_routes import get_misconception_report - - report = await get_misconception_report( - class_id=class_id, unit_id=None, - time_range=TimeRange.MONTH, limit=100 - ) - - if format == ExportFormat.CSV: - csv_content = "concept_id,concept_label,misconception,frequency,unit_id,stop_id\n" - for m in report.most_common: - csv_content += f'"{m.concept_id}","{m.concept_label}","{m.misconception_text}",{m.frequency},"{m.unit_id}","{m.stop_id}"\n' - - return Response( - content=csv_content, - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=misconceptions.csv"} - ) - - return { - "export_date": datetime.utcnow().isoformat(), - "class_id": class_id, - "total_entries": len(report.most_common), - "data": [m.model_dump() for m in report.most_common], - } - - -# ============================================== -# API Endpoints - Dashboard Aggregates -# ============================================== - -@router.get("/dashboard/overview") -async def get_analytics_overview( - time_range: TimeRange = Query(TimeRange.MONTH), -) -> Dict[str, Any]: - """ - Get high-level analytics overview for dashboard. - """ - db = await get_analytics_database() - - if db: - try: - overview = await db.get_analytics_overview(time_range.value) - return overview - except Exception as e: - logger.error(f"Failed to get analytics overview: {e}") - - return { - "time_range": time_range.value, - "total_sessions": 0, - "unique_students": 0, - "avg_completion_rate": 0.0, - "avg_learning_gain": 0.0, - "most_played_units": [], - "struggling_concepts": [], - "active_classes": 0, - } - - -@router.get("/health") -async def health_check() -> Dict[str, Any]: - """Health check for analytics API.""" - db = await get_analytics_database() - return { - "status": "healthy", - "service": "unit-analytics", - "database": "connected" if db else "disconnected", - } +# Backward-compat shim -- module moved to units/analytics_export.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.analytics_export") diff --git a/backend-lehrer/unit_analytics_helpers.py b/backend-lehrer/unit_analytics_helpers.py index 91202c1..0fc2392 100644 --- a/backend-lehrer/unit_analytics_helpers.py +++ b/backend-lehrer/unit_analytics_helpers.py @@ -1,97 +1,4 @@ -""" -Unit Analytics API - Helpers. - -Database access, statistical computation, and utility functions. -""" - -import os -import logging -from typing import List, Dict, Optional - -logger = logging.getLogger(__name__) - -# Feature flags -USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" - -# Database singleton -_analytics_db = None - - -async def get_analytics_database(): - """Get analytics database instance.""" - global _analytics_db - if not USE_DATABASE: - return None - if _analytics_db is None: - try: - from unit.database import get_analytics_db - _analytics_db = await get_analytics_db() - logger.info("Analytics database initialized") - except ImportError: - logger.warning("Analytics database module not available") - except Exception as e: - logger.warning(f"Analytics database not available: {e}") - return _analytics_db - - -def calculate_gain_distribution(gains: List[float]) -> Dict[str, int]: - """Calculate distribution of learning gains into buckets.""" - distribution = { - "< -20%": 0, - "-20% to -10%": 0, - "-10% to 0%": 0, - "0% to 10%": 0, - "10% to 20%": 0, - "> 20%": 0, - } - - for gain in gains: - gain_percent = gain * 100 - if gain_percent < -20: - distribution["< -20%"] += 1 - elif gain_percent < -10: - distribution["-20% to -10%"] += 1 - elif gain_percent < 0: - distribution["-10% to 0%"] += 1 - elif gain_percent < 10: - distribution["0% to 10%"] += 1 - elif gain_percent < 20: - distribution["10% to 20%"] += 1 - else: - distribution["> 20%"] += 1 - - return distribution - - -def calculate_trend(scores: List[float]) -> str: - """Calculate trend from a series of scores.""" - if len(scores) < 3: - return "insufficient_data" - - # Simple linear regression - n = len(scores) - x_mean = (n - 1) / 2 - y_mean = sum(scores) / n - - numerator = sum((i - x_mean) * (scores[i] - y_mean) for i in range(n)) - denominator = sum((i - x_mean) ** 2 for i in range(n)) - - if denominator == 0: - return "stable" - - slope = numerator / denominator - - if slope > 0.05: - return "improving" - elif slope < -0.05: - return "declining" - else: - return "stable" - - -def calculate_difficulty_rating(success_rate: float, avg_attempts: float) -> float: - """Calculate difficulty rating 1-5 based on success metrics.""" - # Lower success rate and higher attempts = higher difficulty - base_difficulty = (1 - success_rate) * 3 + 1 # 1-4 range - attempt_modifier = min(avg_attempts - 1, 1) # 0-1 range - return min(5.0, base_difficulty + attempt_modifier) +# Backward-compat shim -- module moved to units/analytics_helpers.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.analytics_helpers") diff --git a/backend-lehrer/unit_analytics_models.py b/backend-lehrer/unit_analytics_models.py index 5a63938..53ef622 100644 --- a/backend-lehrer/unit_analytics_models.py +++ b/backend-lehrer/unit_analytics_models.py @@ -1,127 +1,4 @@ -""" -Unit Analytics API - Pydantic Models. - -Data models for learning gains, stop performance, misconceptions, -student progress, class comparison, and export. -""" - -from typing import List, Optional, Dict, Any -from datetime import datetime -from enum import Enum - -from pydantic import BaseModel, Field - - -class TimeRange(str, Enum): - """Time range for analytics queries""" - WEEK = "week" - MONTH = "month" - QUARTER = "quarter" - ALL = "all" - - -class LearningGainData(BaseModel): - """Pre/Post learning gain data point""" - student_id: str - student_name: str - unit_id: str - precheck_score: float - postcheck_score: float - learning_gain: float - percentile: Optional[float] = None - - -class LearningGainSummary(BaseModel): - """Aggregated learning gain statistics""" - unit_id: str - unit_title: str - total_students: int - avg_precheck: float - avg_postcheck: float - avg_gain: float - median_gain: float - std_deviation: float - positive_gain_count: int - negative_gain_count: int - no_change_count: int - gain_distribution: Dict[str, int] - individual_gains: List[LearningGainData] - - -class StopPerformance(BaseModel): - """Performance data for a single stop""" - stop_id: str - stop_label: str - attempts_total: int - success_rate: float - avg_time_seconds: float - avg_attempts_before_success: float - common_errors: List[str] - difficulty_rating: float # 1-5 based on performance - - -class UnitPerformanceDetail(BaseModel): - """Detailed unit performance breakdown""" - unit_id: str - unit_title: str - template: str - total_sessions: int - completed_sessions: int - completion_rate: float - avg_duration_minutes: float - stops: List[StopPerformance] - bottleneck_stops: List[str] # Stops where students struggle most - - -class MisconceptionEntry(BaseModel): - """Individual misconception tracking""" - concept_id: str - concept_label: str - misconception_text: str - frequency: int - affected_student_ids: List[str] - unit_id: str - stop_id: str - detected_via: str # "precheck", "postcheck", "interaction" - first_detected: datetime - last_detected: datetime - - -class MisconceptionReport(BaseModel): - """Comprehensive misconception report""" - class_id: Optional[str] - time_range: str - total_misconceptions: int - unique_concepts: int - most_common: List[MisconceptionEntry] - by_unit: Dict[str, List[MisconceptionEntry]] - trending_up: List[MisconceptionEntry] # Getting more frequent - resolved: List[MisconceptionEntry] # No longer appearing - - -class StudentProgressTimeline(BaseModel): - """Timeline of student progress""" - student_id: str - student_name: str - units_completed: int - total_time_minutes: int - avg_score: float - trend: str # "improving", "stable", "declining" - timeline: List[Dict[str, Any]] # List of session events - - -class ClassComparisonData(BaseModel): - """Data for comparing class performance""" - class_id: str - class_name: str - student_count: int - units_assigned: int - avg_completion_rate: float - avg_learning_gain: float - avg_time_per_unit: float - - -class ExportFormat(str, Enum): - """Export format options""" - JSON = "json" - CSV = "csv" +# Backward-compat shim -- module moved to units/analytics_models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.analytics_models") diff --git a/backend-lehrer/unit_analytics_routes.py b/backend-lehrer/unit_analytics_routes.py index 8a11e6d..97e001b 100644 --- a/backend-lehrer/unit_analytics_routes.py +++ b/backend-lehrer/unit_analytics_routes.py @@ -1,394 +1,4 @@ -""" -Unit Analytics API - Routes. - -All API endpoints for learning gain, stop-level, misconception, -student timeline, class comparison, export, and dashboard analytics. -""" - -import logging -import statistics -from datetime import datetime -from typing import Optional, Dict, Any, List - -from fastapi import APIRouter, Query - -from unit_analytics_models import ( - TimeRange, - LearningGainData, - LearningGainSummary, - StopPerformance, - UnitPerformanceDetail, - MisconceptionEntry, - MisconceptionReport, - StudentProgressTimeline, - ClassComparisonData, -) -from unit_analytics_helpers import ( - get_analytics_database, - calculate_gain_distribution, - calculate_trend, - calculate_difficulty_rating, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(tags=["Unit Analytics"]) - - -# ============================================== -# API Endpoints - Learning Gain -# ============================================== - -# NOTE: Static routes must come BEFORE dynamic routes like /{unit_id} -@router.get("/learning-gain/compare") -async def compare_learning_gains( - unit_ids: str = Query(..., description="Comma-separated unit IDs"), - class_id: Optional[str] = Query(None), - time_range: TimeRange = Query(TimeRange.MONTH), -) -> Dict[str, Any]: - """ - Compare learning gains across multiple units. - """ - unit_list = [u.strip() for u in unit_ids.split(",")] - comparisons = [] - - for unit_id in unit_list: - try: - summary = await get_learning_gain_analysis(unit_id, class_id, time_range) - comparisons.append({ - "unit_id": unit_id, - "avg_gain": summary.avg_gain, - "median_gain": summary.median_gain, - "total_students": summary.total_students, - "positive_rate": summary.positive_gain_count / max(summary.total_students, 1), - }) - except Exception as e: - logger.error(f"Failed to get comparison for {unit_id}: {e}") - - return { - "time_range": time_range.value, - "class_id": class_id, - "comparisons": sorted(comparisons, key=lambda x: x["avg_gain"], reverse=True), - } - - -@router.get("/learning-gain/{unit_id}", response_model=LearningGainSummary) -async def get_learning_gain_analysis( - unit_id: str, - class_id: Optional[str] = Query(None, description="Filter by class"), - time_range: TimeRange = Query(TimeRange.MONTH, description="Time range for analysis"), -) -> LearningGainSummary: - """ - Get detailed pre/post learning gain analysis for a unit. - """ - db = await get_analytics_database() - individual_gains = [] - - if db: - try: - sessions = await db.get_unit_sessions_with_scores( - unit_id=unit_id, - class_id=class_id, - time_range=time_range.value - ) - - for session in sessions: - if session.get("precheck_score") is not None and session.get("postcheck_score") is not None: - gain = session["postcheck_score"] - session["precheck_score"] - individual_gains.append(LearningGainData( - student_id=session["student_id"], - student_name=session.get("student_name", session["student_id"][:8]), - unit_id=unit_id, - precheck_score=session["precheck_score"], - postcheck_score=session["postcheck_score"], - learning_gain=gain, - )) - except Exception as e: - logger.error(f"Failed to get learning gain data: {e}") - - # Calculate statistics - if not individual_gains: - return LearningGainSummary( - unit_id=unit_id, - unit_title=f"Unit {unit_id}", - total_students=0, - avg_precheck=0.0, avg_postcheck=0.0, - avg_gain=0.0, median_gain=0.0, std_deviation=0.0, - positive_gain_count=0, negative_gain_count=0, no_change_count=0, - gain_distribution={}, individual_gains=[], - ) - - gains = [g.learning_gain for g in individual_gains] - prechecks = [g.precheck_score for g in individual_gains] - postchecks = [g.postcheck_score for g in individual_gains] - - avg_gain = statistics.mean(gains) - median_gain = statistics.median(gains) - std_dev = statistics.stdev(gains) if len(gains) > 1 else 0.0 - - # Calculate percentiles - sorted_gains = sorted(gains) - for data in individual_gains: - rank = sorted_gains.index(data.learning_gain) + 1 - data.percentile = rank / len(sorted_gains) * 100 - - return LearningGainSummary( - unit_id=unit_id, - unit_title=f"Unit {unit_id}", - total_students=len(individual_gains), - avg_precheck=statistics.mean(prechecks), - avg_postcheck=statistics.mean(postchecks), - avg_gain=avg_gain, - median_gain=median_gain, - std_deviation=std_dev, - positive_gain_count=sum(1 for g in gains if g > 0.01), - negative_gain_count=sum(1 for g in gains if g < -0.01), - no_change_count=sum(1 for g in gains if -0.01 <= g <= 0.01), - gain_distribution=calculate_gain_distribution(gains), - individual_gains=sorted(individual_gains, key=lambda x: x.learning_gain, reverse=True), - ) - - -# ============================================== -# API Endpoints - Stop-Level Analytics -# ============================================== - -@router.get("/unit/{unit_id}/stops", response_model=UnitPerformanceDetail) -async def get_unit_stop_analytics( - unit_id: str, - class_id: Optional[str] = Query(None), - time_range: TimeRange = Query(TimeRange.MONTH), -) -> UnitPerformanceDetail: - """ - Get detailed stop-level performance analytics. - """ - db = await get_analytics_database() - stops_data = [] - - if db: - try: - stop_stats = await db.get_stop_performance( - unit_id=unit_id, class_id=class_id, time_range=time_range.value - ) - - for stop in stop_stats: - difficulty = calculate_difficulty_rating( - stop.get("success_rate", 0.5), - stop.get("avg_attempts", 1.0) - ) - stops_data.append(StopPerformance( - stop_id=stop["stop_id"], - stop_label=stop.get("stop_label", stop["stop_id"]), - attempts_total=stop.get("total_attempts", 0), - success_rate=stop.get("success_rate", 0.0), - avg_time_seconds=stop.get("avg_time_seconds", 0.0), - avg_attempts_before_success=stop.get("avg_attempts", 1.0), - common_errors=stop.get("common_errors", []), - difficulty_rating=difficulty, - )) - - unit_stats = await db.get_unit_overall_stats(unit_id, class_id, time_range.value) - except Exception as e: - logger.error(f"Failed to get stop analytics: {e}") - unit_stats = {} - else: - unit_stats = {} - - # Identify bottleneck stops - bottlenecks = [ - s.stop_id for s in stops_data - if s.difficulty_rating > 3.5 or s.success_rate < 0.6 - ] - - return UnitPerformanceDetail( - unit_id=unit_id, - unit_title=f"Unit {unit_id}", - template=unit_stats.get("template", "unknown"), - total_sessions=unit_stats.get("total_sessions", 0), - completed_sessions=unit_stats.get("completed_sessions", 0), - completion_rate=unit_stats.get("completion_rate", 0.0), - avg_duration_minutes=unit_stats.get("avg_duration_minutes", 0.0), - stops=stops_data, - bottleneck_stops=bottlenecks, - ) - - -# ============================================== -# API Endpoints - Misconception Tracking -# ============================================== - -@router.get("/misconceptions", response_model=MisconceptionReport) -async def get_misconception_report( - class_id: Optional[str] = Query(None), - unit_id: Optional[str] = Query(None), - time_range: TimeRange = Query(TimeRange.MONTH), - limit: int = Query(20, ge=1, le=100), -) -> MisconceptionReport: - """ - Get comprehensive misconception report. - """ - db = await get_analytics_database() - misconceptions = [] - - if db: - try: - raw_misconceptions = await db.get_misconceptions( - class_id=class_id, unit_id=unit_id, - time_range=time_range.value, limit=limit - ) - - for m in raw_misconceptions: - misconceptions.append(MisconceptionEntry( - concept_id=m["concept_id"], - concept_label=m["concept_label"], - misconception_text=m["misconception_text"], - frequency=m["frequency"], - affected_student_ids=m.get("student_ids", []), - unit_id=m["unit_id"], - stop_id=m["stop_id"], - detected_via=m.get("detected_via", "unknown"), - first_detected=m.get("first_detected", datetime.utcnow()), - last_detected=m.get("last_detected", datetime.utcnow()), - )) - except Exception as e: - logger.error(f"Failed to get misconceptions: {e}") - - # Group by unit - by_unit = {} - for m in misconceptions: - if m.unit_id not in by_unit: - by_unit[m.unit_id] = [] - by_unit[m.unit_id].append(m) - - trending_up = misconceptions[:3] if misconceptions else [] - resolved = [] - - return MisconceptionReport( - class_id=class_id, - time_range=time_range.value, - total_misconceptions=sum(m.frequency for m in misconceptions), - unique_concepts=len(set(m.concept_id for m in misconceptions)), - most_common=sorted(misconceptions, key=lambda x: x.frequency, reverse=True)[:10], - by_unit=by_unit, - trending_up=trending_up, - resolved=resolved, - ) - - -@router.get("/misconceptions/student/{student_id}") -async def get_student_misconceptions( - student_id: str, - time_range: TimeRange = Query(TimeRange.ALL), -) -> Dict[str, Any]: - """ - Get misconceptions for a specific student. - """ - db = await get_analytics_database() - - if db: - try: - misconceptions = await db.get_student_misconceptions( - student_id=student_id, time_range=time_range.value - ) - return { - "student_id": student_id, - "misconceptions": misconceptions, - "recommended_remediation": [ - {"concept": m["concept_label"], "activity": f"Review {m['unit_id']}/{m['stop_id']}"} - for m in misconceptions[:5] - ] - } - except Exception as e: - logger.error(f"Failed to get student misconceptions: {e}") - - return { - "student_id": student_id, - "misconceptions": [], - "recommended_remediation": [], - } - - -# ============================================== -# API Endpoints - Student Progress Timeline -# ============================================== - -@router.get("/student/{student_id}/timeline", response_model=StudentProgressTimeline) -async def get_student_timeline( - student_id: str, - time_range: TimeRange = Query(TimeRange.ALL), -) -> StudentProgressTimeline: - """ - Get detailed progress timeline for a student. - """ - db = await get_analytics_database() - timeline = [] - scores = [] - - if db: - try: - sessions = await db.get_student_sessions( - student_id=student_id, time_range=time_range.value - ) - - for session in sessions: - timeline.append({ - "date": session.get("started_at"), - "unit_id": session.get("unit_id"), - "completed": session.get("completed_at") is not None, - "precheck": session.get("precheck_score"), - "postcheck": session.get("postcheck_score"), - "duration_minutes": session.get("duration_seconds", 0) // 60, - }) - if session.get("postcheck_score") is not None: - scores.append(session["postcheck_score"]) - except Exception as e: - logger.error(f"Failed to get student timeline: {e}") - - trend = calculate_trend(scores) if scores else "insufficient_data" - - return StudentProgressTimeline( - student_id=student_id, - student_name=f"Student {student_id[:8]}", - units_completed=sum(1 for t in timeline if t["completed"]), - total_time_minutes=sum(t["duration_minutes"] for t in timeline), - avg_score=statistics.mean(scores) if scores else 0.0, - trend=trend, - timeline=timeline, - ) - - -# ============================================== -# API Endpoints - Class Comparison -# ============================================== - -@router.get("/compare/classes", response_model=List[ClassComparisonData]) -async def compare_classes( - class_ids: str = Query(..., description="Comma-separated class IDs"), - time_range: TimeRange = Query(TimeRange.MONTH), -) -> List[ClassComparisonData]: - """ - Compare performance across multiple classes. - """ - class_list = [c.strip() for c in class_ids.split(",")] - comparisons = [] - - db = await get_analytics_database() - if db: - for class_id in class_list: - try: - stats = await db.get_class_aggregate_stats(class_id, time_range.value) - comparisons.append(ClassComparisonData( - class_id=class_id, - class_name=stats.get("class_name", f"Klasse {class_id[:8]}"), - student_count=stats.get("student_count", 0), - units_assigned=stats.get("units_assigned", 0), - avg_completion_rate=stats.get("avg_completion_rate", 0.0), - avg_learning_gain=stats.get("avg_learning_gain", 0.0), - avg_time_per_unit=stats.get("avg_time_per_unit", 0.0), - )) - except Exception as e: - logger.error(f"Failed to get stats for class {class_id}: {e}") - - return sorted(comparisons, key=lambda x: x.avg_learning_gain, reverse=True) - - +# Backward-compat shim -- module moved to units/analytics_routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.analytics_routes") diff --git a/backend-lehrer/unit_api.py b/backend-lehrer/unit_api.py index 1f534f4..2052435 100644 --- a/backend-lehrer/unit_api.py +++ b/backend-lehrer/unit_api.py @@ -1,57 +1,4 @@ -# ============================================== -# Breakpilot Drive - Unit API (barrel re-export) -# ============================================== -# This module was split into: -# - unit_models.py (Pydantic models) -# - unit_helpers.py (Auth, DB, token, validation helpers) -# - unit_routes.py (Definition, session, analytics routes) -# - unit_content_routes.py (H5P, worksheet, PDF routes) -# -# The `router` object is assembled here by including all sub-routers. -# Importers that did `from unit_api import router` continue to work. - -from fastapi import APIRouter - -from unit_routes import router as _routes_router -from unit_definition_routes import router as _definition_router -from unit_content_routes import router as _content_router - -# Re-export models for any direct importers -from unit_models import ( # noqa: F401 - UnitDefinitionResponse, - CreateSessionRequest, - SessionResponse, - TelemetryEvent, - TelemetryPayload, - TelemetryResponse, - PostcheckAnswer, - CompleteSessionRequest, - SessionSummaryResponse, - UnitListItem, - RecommendedUnit, - CreateUnitRequest, - UpdateUnitRequest, - ValidationError, - ValidationResult, -) - -# Re-export helpers for any direct importers -from unit_helpers import ( # noqa: F401 - get_optional_current_user, - get_unit_database, - create_session_token, - verify_session_token, - get_session_from_token, - validate_unit_definition, - USE_DATABASE, - REQUIRE_AUTH, - SECRET_KEY, -) - -# Assemble the combined router. -# _routes_router and _content_router both use prefix="/api/units", -# so we create a plain router and include them without extra prefix. -router = APIRouter() -router.include_router(_routes_router) -router.include_router(_definition_router) -router.include_router(_content_router) +# Backward-compat shim -- module moved to units/api.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.api") diff --git a/backend-lehrer/unit_content_routes.py b/backend-lehrer/unit_content_routes.py index dcf745d..70a2efa 100644 --- a/backend-lehrer/unit_content_routes.py +++ b/backend-lehrer/unit_content_routes.py @@ -1,160 +1,4 @@ -# ============================================== -# Breakpilot Drive - Unit Content Generation Routes -# ============================================== -# API endpoints for H5P content, worksheets, and PDF generation. -# Extracted from unit_api.py for file-size compliance. - -from fastapi import APIRouter, HTTPException, Query, Depends -from typing import Optional, Dict, Any -import logging - -from unit_models import UnitDefinitionResponse -from unit_helpers import get_optional_current_user, get_unit_database - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) - - -@router.get("/content/{unit_id}/h5p") -async def generate_h5p_content( - unit_id: str, - locale: str = Query("de-DE", description="Target locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Generate H5P content items for a unit. - - Returns H5P-compatible content structures for: - - Drag and Drop (vocabulary matching) - - Fill in the Blanks (concept texts) - - Multiple Choice (misconception targeting) - """ - from content_generators import generate_h5p_for_unit, H5PGenerator, generate_h5p_manifest - - # Get unit definition - db = await get_unit_database() - unit_def = None - - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - unit_def = unit.get("definition", {}) - except Exception as e: - logger.error(f"Failed to get unit for H5P generation: {e}") - - if not unit_def: - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - try: - generator = H5PGenerator(locale=locale) - contents = generator.generate_from_unit(unit_def) - manifest = generate_h5p_manifest(contents, unit_id) - - return { - "unit_id": unit_id, - "locale": locale, - "generated_count": len(contents), - "manifest": manifest, - "contents": [c.to_h5p_structure() for c in contents] - } - except Exception as e: - logger.error(f"H5P generation failed: {e}") - raise HTTPException(status_code=500, detail=f"H5P generation failed: {str(e)}") - - -@router.get("/content/{unit_id}/worksheet") -async def generate_worksheet_html( - unit_id: str, - locale: str = Query("de-DE", description="Target locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Generate worksheet HTML for a unit. - - Returns HTML that can be: - - Displayed in browser - - Converted to PDF using weasyprint - - Printed directly - """ - from content_generators import PDFGenerator - - # Get unit definition - db = await get_unit_database() - unit_def = None - - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - unit_def = unit.get("definition", {}) - except Exception as e: - logger.error(f"Failed to get unit for worksheet generation: {e}") - - if not unit_def: - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - try: - generator = PDFGenerator(locale=locale) - worksheet = generator.generate_from_unit(unit_def) - - return { - "unit_id": unit_id, - "locale": locale, - "title": worksheet.title, - "sections": len(worksheet.sections), - "html": worksheet.to_html() - } - except Exception as e: - logger.error(f"Worksheet generation failed: {e}") - raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}") - - -@router.get("/content/{unit_id}/worksheet.pdf") -async def download_worksheet_pdf( - unit_id: str, - locale: str = Query("de-DE", description="Target locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -): - """ - Generate and download worksheet as PDF. - - Requires weasyprint to be installed on the server. - """ - from fastapi.responses import Response - - # Get unit definition - db = await get_unit_database() - unit_def = None - - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - unit_def = unit.get("definition", {}) - except Exception as e: - logger.error(f"Failed to get unit for PDF generation: {e}") - - if not unit_def: - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - try: - from content_generators import generate_worksheet_pdf - pdf_bytes = generate_worksheet_pdf(unit_def, locale) - - return Response( - content=pdf_bytes, - media_type="application/pdf", - headers={ - "Content-Disposition": f'attachment; filename="{unit_id}_worksheet.pdf"' - } - ) - except ImportError: - raise HTTPException( - status_code=501, - detail="PDF generation not available. Install weasyprint: pip install weasyprint" - ) - except Exception as e: - logger.error(f"PDF generation failed: {e}") - raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}") +# Backward-compat shim -- module moved to units/content_routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.content_routes") diff --git a/backend-lehrer/unit_definition_routes.py b/backend-lehrer/unit_definition_routes.py index f8d875d..e1ae0b7 100644 --- a/backend-lehrer/unit_definition_routes.py +++ b/backend-lehrer/unit_definition_routes.py @@ -1,301 +1,4 @@ -# ============================================== -# Breakpilot Drive - Unit Definition CRUD Routes -# ============================================== -# Endpoints for creating, updating, deleting, and validating -# unit definitions. Extracted from unit_routes.py for file-size compliance. - -from fastapi import APIRouter, HTTPException, Query, Depends -from typing import Optional, Dict, Any -from datetime import datetime -import logging - -from unit_models import ( - UnitDefinitionResponse, - CreateUnitRequest, - UpdateUnitRequest, - ValidationResult, -) -from unit_helpers import ( - get_optional_current_user, - get_unit_database, - validate_unit_definition, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) - - -@router.post("/definitions", response_model=UnitDefinitionResponse) -async def create_unit_definition( - request_data: CreateUnitRequest, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> UnitDefinitionResponse: - """ - Create a new unit definition. - - - Validates unit structure - - Saves to database or JSON file - - Returns created unit - """ - import json - from pathlib import Path - - # Build full definition - definition = { - "unit_id": request_data.unit_id, - "template": request_data.template, - "version": request_data.version, - "locale": request_data.locale, - "grade_band": request_data.grade_band, - "duration_minutes": request_data.duration_minutes, - "difficulty": request_data.difficulty, - "subject": request_data.subject, - "topic": request_data.topic, - "learning_objectives": request_data.learning_objectives, - "stops": request_data.stops, - "precheck": request_data.precheck or { - "question_set_id": f"{request_data.unit_id}_precheck", - "required": True, - "time_limit_seconds": 120 - }, - "postcheck": request_data.postcheck or { - "question_set_id": f"{request_data.unit_id}_postcheck", - "required": True, - "time_limit_seconds": 180 - }, - "teacher_controls": request_data.teacher_controls or { - "allow_skip": True, - "allow_replay": True, - "max_time_per_stop_sec": 90, - "show_hints": True, - "require_precheck": True, - "require_postcheck": True - }, - "assets": request_data.assets or {}, - "metadata": request_data.metadata or { - "author": user.get("email", "Unknown") if user else "Unknown", - "created": datetime.utcnow().isoformat(), - "curriculum_reference": "" - } - } - - # Validate - validation = validate_unit_definition(definition) - if not validation.valid: - error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] - raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") - - # Check if unit_id already exists - db = await get_unit_database() - if db: - try: - existing = await db.get_unit_definition(request_data.unit_id) - if existing: - raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") - - # Save to database - await db.create_unit_definition( - unit_id=request_data.unit_id, - template=request_data.template, - version=request_data.version, - locale=request_data.locale, - grade_band=request_data.grade_band, - duration_minutes=request_data.duration_minutes, - difficulty=request_data.difficulty, - definition=definition, - status=request_data.status - ) - logger.info(f"Unit created in database: {request_data.unit_id}") - except HTTPException: - raise - except Exception as e: - logger.warning(f"Database save failed, using JSON fallback: {e}") - # Fallback to JSON - units_dir = Path(__file__).parent / "data" / "units" - units_dir.mkdir(parents=True, exist_ok=True) - json_path = units_dir / f"{request_data.unit_id}.json" - if json_path.exists(): - raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - logger.info(f"Unit created as JSON: {json_path}") - else: - # JSON only mode - units_dir = Path(__file__).parent / "data" / "units" - units_dir.mkdir(parents=True, exist_ok=True) - json_path = units_dir / f"{request_data.unit_id}.json" - if json_path.exists(): - raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - logger.info(f"Unit created as JSON: {json_path}") - - return UnitDefinitionResponse( - unit_id=request_data.unit_id, - template=request_data.template, - version=request_data.version, - locale=request_data.locale, - grade_band=request_data.grade_band, - duration_minutes=request_data.duration_minutes, - difficulty=request_data.difficulty, - definition=definition - ) - - -@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse) -async def update_unit_definition( - unit_id: str, - request_data: UpdateUnitRequest, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> UnitDefinitionResponse: - """ - Update an existing unit definition. - - - Merges updates with existing definition - - Re-validates - - Saves updated version - """ - import json - from pathlib import Path - - # Get existing unit - db = await get_unit_database() - existing = None - - if db: - try: - existing = await db.get_unit_definition(unit_id) - except Exception as e: - logger.warning(f"Database read failed: {e}") - - if not existing: - # Try JSON file - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - if json_path.exists(): - with open(json_path, "r", encoding="utf-8") as f: - file_data = json.load(f) - existing = { - "unit_id": file_data.get("unit_id"), - "template": file_data.get("template"), - "version": file_data.get("version", "1.0.0"), - "locale": file_data.get("locale", ["de-DE"]), - "grade_band": file_data.get("grade_band", []), - "duration_minutes": file_data.get("duration_minutes", 8), - "difficulty": file_data.get("difficulty", "base"), - "definition": file_data - } - - if not existing: - raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") - - # Merge updates into existing definition - definition = existing.get("definition", {}) - update_dict = request_data.model_dump(exclude_unset=True) - - for key, value in update_dict.items(): - if value is not None: - definition[key] = value - - # Validate updated definition - validation = validate_unit_definition(definition) - if not validation.valid: - error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] - raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") - - # Save - if db: - try: - await db.update_unit_definition( - unit_id=unit_id, - version=definition.get("version"), - locale=definition.get("locale"), - grade_band=definition.get("grade_band"), - duration_minutes=definition.get("duration_minutes"), - difficulty=definition.get("difficulty"), - definition=definition, - status=update_dict.get("status") - ) - logger.info(f"Unit updated in database: {unit_id}") - except Exception as e: - logger.warning(f"Database update failed, using JSON: {e}") - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - else: - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - with open(json_path, "w", encoding="utf-8") as f: - json.dump(definition, f, ensure_ascii=False, indent=2) - logger.info(f"Unit updated as JSON: {json_path}") - - return UnitDefinitionResponse( - unit_id=unit_id, - template=definition.get("template", existing.get("template")), - version=definition.get("version", existing.get("version", "1.0.0")), - locale=definition.get("locale", existing.get("locale", ["de-DE"])), - grade_band=definition.get("grade_band", existing.get("grade_band", [])), - duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)), - difficulty=definition.get("difficulty", existing.get("difficulty", "base")), - definition=definition - ) - - -@router.delete("/definitions/{unit_id}") -async def delete_unit_definition( - unit_id: str, - force: bool = Query(False, description="Force delete even if published"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Delete a unit definition. - - - By default, only drafts can be deleted - - Use force=true to delete published units - """ - from pathlib import Path - - db = await get_unit_database() - deleted = False - - if db: - try: - existing = await db.get_unit_definition(unit_id) - if existing: - status = existing.get("status", "draft") - if status == "published" and not force: - raise HTTPException( - status_code=400, - detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true." - ) - await db.delete_unit_definition(unit_id) - deleted = True - logger.info(f"Unit deleted from database: {unit_id}") - except HTTPException: - raise - except Exception as e: - logger.warning(f"Database delete failed: {e}") - - # Also check JSON file - json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" - if json_path.exists(): - json_path.unlink() - deleted = True - logger.info(f"Unit JSON deleted: {json_path}") - - if not deleted: - raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") - - return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"} - - -@router.post("/definitions/validate", response_model=ValidationResult) -async def validate_unit( - unit_data: Dict[str, Any], - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> ValidationResult: - """ - Validate a unit definition without saving. - - Returns validation result with errors and warnings. - """ - return validate_unit_definition(unit_data) +# Backward-compat shim -- module moved to units/definition_routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.definition_routes") diff --git a/backend-lehrer/unit_helpers.py b/backend-lehrer/unit_helpers.py index cc0eba1..9cdddc8 100644 --- a/backend-lehrer/unit_helpers.py +++ b/backend-lehrer/unit_helpers.py @@ -1,204 +1,4 @@ -# ============================================== -# Breakpilot Drive - Unit API Helpers -# ============================================== -# Auth, database, token, and validation helpers for the Unit API. -# Extracted from unit_api.py for file-size compliance. - -from fastapi import HTTPException, Request -from typing import Optional, Dict, Any, List -from datetime import datetime, timedelta -import os -import logging -import jwt - -from unit_models import ValidationError, ValidationResult - -logger = logging.getLogger(__name__) - -# Feature flags -USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" -REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" -SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production") - - -# ============================================== -# Auth Dependency (reuse from game_api) -# ============================================== - -async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: - """Optional auth dependency for Unit API.""" - if not REQUIRE_AUTH: - return None - - try: - from auth import get_current_user - return await get_current_user(request) - except ImportError: - logger.warning("Auth module not available") - return None - except HTTPException: - raise - except Exception as e: - logger.error(f"Auth error: {e}") - raise HTTPException(status_code=401, detail="Authentication failed") - - -# ============================================== -# Database Integration -# ============================================== - -_unit_db = None - -async def get_unit_database(): - """Get unit database instance with lazy initialization.""" - global _unit_db - if not USE_DATABASE: - return None - if _unit_db is None: - try: - from unit.database import get_unit_db - _unit_db = await get_unit_db() - logger.info("Unit database initialized") - except ImportError: - logger.warning("Unit database module not available") - except Exception as e: - logger.warning(f"Unit database not available: {e}") - return _unit_db - - -# ============================================== -# Token Helpers -# ============================================== - -def create_session_token(session_id: str, student_id: str, expires_hours: int = 4) -> str: - """Create a JWT session token for telemetry authentication.""" - payload = { - "session_id": session_id, - "student_id": student_id, - "exp": datetime.utcnow() + timedelta(hours=expires_hours), - "iat": datetime.utcnow(), - } - return jwt.encode(payload, SECRET_KEY, algorithm="HS256") - - -def verify_session_token(token: str) -> Optional[Dict[str, Any]]: - """Verify a session token and return payload.""" - try: - return jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) - except jwt.ExpiredSignatureError: - return None - except jwt.InvalidTokenError: - return None - - -async def get_session_from_token(request: Request) -> Optional[Dict[str, Any]]: - """Extract and verify session from Authorization header.""" - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - return None - token = auth_header[7:] - return verify_session_token(token) - - -# ============================================== -# Validation -# ============================================== - -def validate_unit_definition(unit_data: Dict[str, Any]) -> ValidationResult: - """ - Validate a unit definition structure. - - Returns validation result with errors and warnings. - """ - errors: List[ValidationError] = [] - warnings: List[ValidationError] = [] - - # Required fields - if not unit_data.get("unit_id"): - errors.append(ValidationError(field="unit_id", message="unit_id ist erforderlich")) - - if not unit_data.get("template"): - errors.append(ValidationError(field="template", message="template ist erforderlich")) - elif unit_data["template"] not in ["flight_path", "station_loop"]: - errors.append(ValidationError( - field="template", - message="template muss 'flight_path' oder 'station_loop' sein" - )) - - # Validate stops - stops = unit_data.get("stops", []) - if not stops: - errors.append(ValidationError(field="stops", message="Mindestens 1 Stop erforderlich")) - else: - # Check minimum stops for flight_path - if unit_data.get("template") == "flight_path" and len(stops) < 3: - warnings.append(ValidationError( - field="stops", - message="FlightPath sollte mindestens 3 Stops haben", - severity="warning" - )) - - # Validate each stop - stop_ids = set() - for i, stop in enumerate(stops): - if not stop.get("stop_id"): - errors.append(ValidationError( - field=f"stops[{i}].stop_id", - message=f"Stop {i}: stop_id fehlt" - )) - else: - if stop["stop_id"] in stop_ids: - errors.append(ValidationError( - field=f"stops[{i}].stop_id", - message=f"Stop {i}: Doppelte stop_id '{stop['stop_id']}'" - )) - stop_ids.add(stop["stop_id"]) - - # Check interaction type - interaction = stop.get("interaction", {}) - if not interaction.get("type"): - errors.append(ValidationError( - field=f"stops[{i}].interaction.type", - message=f"Stop {stop.get('stop_id', i)}: Interaktionstyp fehlt" - )) - elif interaction["type"] not in [ - "aim_and_pass", "slider_adjust", "slider_equivalence", - "sequence_arrange", "toggle_switch", "drag_match", - "error_find", "transfer_apply" - ]: - warnings.append(ValidationError( - field=f"stops[{i}].interaction.type", - message=f"Stop {stop.get('stop_id', i)}: Unbekannter Interaktionstyp '{interaction['type']}'", - severity="warning" - )) - - # Check for label - if not stop.get("label"): - warnings.append(ValidationError( - field=f"stops[{i}].label", - message=f"Stop {stop.get('stop_id', i)}: Label fehlt", - severity="warning" - )) - - # Validate duration - duration = unit_data.get("duration_minutes", 0) - if duration < 3 or duration > 20: - warnings.append(ValidationError( - field="duration_minutes", - message="Dauer sollte zwischen 3 und 20 Minuten liegen", - severity="warning" - )) - - # Validate difficulty - if unit_data.get("difficulty") and unit_data["difficulty"] not in ["base", "advanced"]: - warnings.append(ValidationError( - field="difficulty", - message="difficulty sollte 'base' oder 'advanced' sein", - severity="warning" - )) - - return ValidationResult( - valid=len(errors) == 0, - errors=errors, - warnings=warnings - ) +# Backward-compat shim -- module moved to units/helpers.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.helpers") diff --git a/backend-lehrer/unit_models.py b/backend-lehrer/unit_models.py index dce74b9..b6c4c8c 100644 --- a/backend-lehrer/unit_models.py +++ b/backend-lehrer/unit_models.py @@ -1,149 +1,4 @@ -# ============================================== -# Breakpilot Drive - Unit API Models -# ============================================== -# Pydantic models for the Unit API. -# Extracted from unit_api.py for file-size compliance. - -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any -from datetime import datetime - - -class UnitDefinitionResponse(BaseModel): - """Unit definition response""" - unit_id: str - template: str - version: str - locale: List[str] - grade_band: List[str] - duration_minutes: int - difficulty: str - definition: Dict[str, Any] - - -class CreateSessionRequest(BaseModel): - """Request to create a unit session""" - unit_id: str - student_id: str - locale: str = "de-DE" - difficulty: str = "base" - - -class SessionResponse(BaseModel): - """Response after creating a session""" - session_id: str - unit_definition_url: str - session_token: str - telemetry_endpoint: str - expires_at: datetime - - -class TelemetryEvent(BaseModel): - """Single telemetry event""" - ts: Optional[str] = None - type: str = Field(..., alias="type") - stop_id: Optional[str] = None - metrics: Optional[Dict[str, Any]] = None - - class Config: - populate_by_name = True - - -class TelemetryPayload(BaseModel): - """Batch telemetry payload""" - session_id: str - events: List[TelemetryEvent] - - -class TelemetryResponse(BaseModel): - """Response after receiving telemetry""" - accepted: int - - -class PostcheckAnswer(BaseModel): - """Single postcheck answer""" - question_id: str - answer: str - - -class CompleteSessionRequest(BaseModel): - """Request to complete a session""" - postcheck_answers: Optional[List[PostcheckAnswer]] = None - - -class SessionSummaryResponse(BaseModel): - """Response with session summary""" - summary: Dict[str, Any] - next_recommendations: Dict[str, Any] - - -class UnitListItem(BaseModel): - """Unit list item""" - unit_id: str - template: str - difficulty: str - duration_minutes: int - locale: List[str] - grade_band: List[str] - - -class RecommendedUnit(BaseModel): - """Recommended unit with reason""" - unit_id: str - template: str - difficulty: str - reason: str - - -class CreateUnitRequest(BaseModel): - """Request to create a new unit definition""" - unit_id: str = Field(..., description="Unique unit identifier") - template: str = Field(..., description="Template type: flight_path or station_loop") - version: str = Field(default="1.0.0", description="Version string") - locale: List[str] = Field(default=["de-DE"], description="Supported locales") - grade_band: List[str] = Field(default=["5", "6", "7"], description="Target grade levels") - duration_minutes: int = Field(default=8, ge=3, le=20, description="Expected duration") - difficulty: str = Field(default="base", description="Difficulty level: base or advanced") - subject: Optional[str] = Field(default=None, description="Subject area") - topic: Optional[str] = Field(default=None, description="Topic within subject") - learning_objectives: List[str] = Field(default=[], description="Learning objectives") - stops: List[Dict[str, Any]] = Field(default=[], description="Unit stops/stations") - precheck: Optional[Dict[str, Any]] = Field(default=None, description="Pre-check configuration") - postcheck: Optional[Dict[str, Any]] = Field(default=None, description="Post-check configuration") - teacher_controls: Optional[Dict[str, Any]] = Field(default=None, description="Teacher control settings") - assets: Optional[Dict[str, Any]] = Field(default=None, description="Asset configuration") - metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata") - status: str = Field(default="draft", description="Publication status: draft or published") - - -class UpdateUnitRequest(BaseModel): - """Request to update an existing unit definition""" - version: Optional[str] = None - locale: Optional[List[str]] = None - grade_band: Optional[List[str]] = None - duration_minutes: Optional[int] = Field(default=None, ge=3, le=20) - difficulty: Optional[str] = None - subject: Optional[str] = None - topic: Optional[str] = None - learning_objectives: Optional[List[str]] = None - stops: Optional[List[Dict[str, Any]]] = None - precheck: Optional[Dict[str, Any]] = None - postcheck: Optional[Dict[str, Any]] = None - teacher_controls: Optional[Dict[str, Any]] = None - assets: Optional[Dict[str, Any]] = None - metadata: Optional[Dict[str, Any]] = None - status: Optional[str] = None - - -class ValidationError(BaseModel): - """Single validation error""" - field: str - message: str - severity: str = "error" # error or warning - - -class ValidationResult(BaseModel): - """Result of unit validation""" - valid: bool - errors: List[ValidationError] = [] - warnings: List[ValidationError] = [] +# Backward-compat shim -- module moved to units/models.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.models") diff --git a/backend-lehrer/unit_routes.py b/backend-lehrer/unit_routes.py index 7f6d7bf..d8f0257 100644 --- a/backend-lehrer/unit_routes.py +++ b/backend-lehrer/unit_routes.py @@ -1,494 +1,4 @@ -# ============================================== -# Breakpilot Drive - Unit API Routes -# ============================================== -# Endpoints for listing/getting definitions, sessions, telemetry, -# recommendations, and analytics. -# CRUD definition routes are in unit_definition_routes.py. -# Extracted from unit_api.py for file-size compliance. - -from fastapi import APIRouter, HTTPException, Query, Depends, Request -from typing import List, Optional, Dict, Any -from datetime import datetime, timedelta -import uuid -import logging - -from unit_models import ( - UnitDefinitionResponse, - CreateSessionRequest, - SessionResponse, - TelemetryPayload, - TelemetryResponse, - CompleteSessionRequest, - SessionSummaryResponse, - UnitListItem, - RecommendedUnit, -) -from unit_helpers import ( - get_optional_current_user, - get_unit_database, - create_session_token, - get_session_from_token, - REQUIRE_AUTH, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) - - -# ============================================== -# Definition List/Get Endpoints -# ============================================== - -@router.get("/definitions", response_model=List[UnitListItem]) -async def list_unit_definitions( - template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"), - grade: Optional[str] = Query(None, description="Filter by grade level"), - locale: str = Query("de-DE", description="Filter by locale"), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[UnitListItem]: - """ - List available unit definitions. - - Returns published units matching the filter criteria. - """ - db = await get_unit_database() - if db: - try: - units = await db.list_units( - template=template, - grade=grade, - locale=locale, - published_only=True - ) - return [ - UnitListItem( - unit_id=u["unit_id"], - template=u["template"], - difficulty=u["difficulty"], - duration_minutes=u["duration_minutes"], - locale=u["locale"], - grade_band=u["grade_band"], - ) - for u in units - ] - except Exception as e: - logger.error(f"Failed to list units: {e}") - - # Fallback: return demo unit - return [ - UnitListItem( - unit_id="demo_unit_v1", - template="flight_path", - difficulty="base", - duration_minutes=5, - locale=["de-DE"], - grade_band=["5", "6", "7"], - ) - ] - - -@router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse) -async def get_unit_definition( - unit_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> UnitDefinitionResponse: - """ - Get a specific unit definition. - - Returns the full unit configuration including stops, interactions, etc. - """ - db = await get_unit_database() - if db: - try: - unit = await db.get_unit_definition(unit_id) - if unit: - return UnitDefinitionResponse( - unit_id=unit["unit_id"], - template=unit["template"], - version=unit["version"], - locale=unit["locale"], - grade_band=unit["grade_band"], - duration_minutes=unit["duration_minutes"], - difficulty=unit["difficulty"], - definition=unit["definition"], - ) - except Exception as e: - logger.error(f"Failed to get unit definition: {e}") - - # Demo unit fallback - if unit_id == "demo_unit_v1": - return UnitDefinitionResponse( - unit_id="demo_unit_v1", - template="flight_path", - version="1.0.0", - locale=["de-DE"], - grade_band=["5", "6", "7"], - duration_minutes=5, - difficulty="base", - definition={ - "unit_id": "demo_unit_v1", - "template": "flight_path", - "version": "1.0.0", - "learning_objectives": ["Demo: Grundfunktion testen"], - "stops": [ - {"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}}, - {"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}}, - {"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}}, - ], - "teacher_controls": {"allow_skip": True, "allow_replay": True}, - }, - ) - - raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") - - -# ============================================== -# Session Endpoints -# ============================================== - -@router.post("/sessions", response_model=SessionResponse) -async def create_unit_session( - request_data: CreateSessionRequest, - request: Request, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> SessionResponse: - """ - Create a new unit session. - - - Validates unit exists - - Creates session record - - Returns session token for telemetry - """ - session_id = str(uuid.uuid4()) - expires_at = datetime.utcnow() + timedelta(hours=4) - - # Validate unit exists - db = await get_unit_database() - if db: - try: - unit = await db.get_unit_definition(request_data.unit_id) - if not unit: - raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}") - - # Create session in database - total_stops = len(unit.get("definition", {}).get("stops", [])) - await db.create_session( - session_id=session_id, - unit_id=request_data.unit_id, - student_id=request_data.student_id, - locale=request_data.locale, - difficulty=request_data.difficulty, - total_stops=total_stops, - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to create session: {e}") - # Continue with in-memory fallback - - # Create session token - session_token = create_session_token(session_id, request_data.student_id) - - # Build definition URL - base_url = str(request.base_url).rstrip("/") - definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}" - - return SessionResponse( - session_id=session_id, - unit_definition_url=definition_url, - session_token=session_token, - telemetry_endpoint="/api/units/telemetry", - expires_at=expires_at, - ) - - -@router.post("/telemetry", response_model=TelemetryResponse) -async def receive_telemetry( - payload: TelemetryPayload, - request: Request, -) -> TelemetryResponse: - """ - Receive batched telemetry events from Unity client. - - - Validates session token - - Stores events in database - - Returns count of accepted events - """ - # Verify session token - session_data = await get_session_from_token(request) - if session_data is None: - # Allow without auth in dev mode - if REQUIRE_AUTH: - raise HTTPException(status_code=401, detail="Invalid or expired session token") - logger.warning("Telemetry received without valid token (dev mode)") - - # Verify session_id matches - if session_data and session_data.get("session_id") != payload.session_id: - raise HTTPException(status_code=403, detail="Session ID mismatch") - - accepted = 0 - db = await get_unit_database() - - for event in payload.events: - try: - # Set timestamp if not provided - timestamp = event.ts or datetime.utcnow().isoformat() - - if db: - await db.store_telemetry_event( - session_id=payload.session_id, - event_type=event.type, - stop_id=event.stop_id, - timestamp=timestamp, - metrics=event.metrics, - ) - - accepted += 1 - logger.debug(f"Telemetry: {event.type} for session {payload.session_id}") - - except Exception as e: - logger.error(f"Failed to store telemetry event: {e}") - - return TelemetryResponse(accepted=accepted) - - -@router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse) -async def complete_session( - session_id: str, - request_data: CompleteSessionRequest, - request: Request, -) -> SessionSummaryResponse: - """ - Complete a unit session. - - - Processes postcheck answers if provided - - Calculates learning gain - - Returns summary and recommendations - """ - # Verify session token - session_data = await get_session_from_token(request) - if REQUIRE_AUTH and session_data is None: - raise HTTPException(status_code=401, detail="Invalid or expired session token") - - db = await get_unit_database() - summary = {} - recommendations = {} - - if db: - try: - # Get session data - session = await db.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - # Calculate postcheck score if answers provided - postcheck_score = None - if request_data.postcheck_answers: - # Simple scoring: count correct answers - # In production, would validate against question bank - postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder - postcheck_score = min(postcheck_score, 1.0) - - # Complete session in database - await db.complete_session( - session_id=session_id, - postcheck_score=postcheck_score, - ) - - # Get updated session summary - session = await db.get_session(session_id) - - # Calculate learning gain - pre_score = session.get("precheck_score") - post_score = session.get("postcheck_score") - learning_gain = None - if pre_score is not None and post_score is not None: - learning_gain = post_score - pre_score - - summary = { - "session_id": session_id, - "unit_id": session.get("unit_id"), - "duration_seconds": session.get("duration_seconds"), - "completion_rate": session.get("completion_rate"), - "precheck_score": pre_score, - "postcheck_score": post_score, - "pre_to_post_gain": learning_gain, - "stops_completed": session.get("stops_completed"), - "total_stops": session.get("total_stops"), - } - - # Get recommendations - recommendations = await db.get_recommendations( - student_id=session.get("student_id"), - completed_unit_id=session.get("unit_id"), - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to complete session: {e}") - summary = {"session_id": session_id, "error": str(e)} - - else: - # Fallback summary - summary = { - "session_id": session_id, - "duration_seconds": 0, - "completion_rate": 1.0, - "message": "Database not available", - } - - return SessionSummaryResponse( - summary=summary, - next_recommendations=recommendations or { - "h5p_activity_ids": [], - "worksheet_pdf_url": None, - }, - ) - - -@router.get("/sessions/{session_id}") -async def get_session( - session_id: str, - request: Request, -) -> Dict[str, Any]: - """ - Get session details. - - Returns current state of a session including progress. - """ - # Verify session token - session_data = await get_session_from_token(request) - if REQUIRE_AUTH and session_data is None: - raise HTTPException(status_code=401, detail="Invalid or expired session token") - - db = await get_unit_database() - if db: - try: - session = await db.get_session(session_id) - if session: - return session - except Exception as e: - logger.error(f"Failed to get session: {e}") - - raise HTTPException(status_code=404, detail="Session not found") - - -# ============================================== -# Recommendations & Analytics -# ============================================== - -@router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit]) -async def get_recommendations( - student_id: str, - grade: Optional[str] = Query(None, description="Grade level filter"), - locale: str = Query("de-DE", description="Locale filter"), - limit: int = Query(5, ge=1, le=20), - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> List[RecommendedUnit]: - """ - Get recommended units for a student. - - Based on completion status and performance. - """ - db = await get_unit_database() - if db: - try: - recommendations = await db.get_student_recommendations( - student_id=student_id, - grade=grade, - locale=locale, - limit=limit, - ) - return [ - RecommendedUnit( - unit_id=r["unit_id"], - template=r["template"], - difficulty=r["difficulty"], - reason=r["reason"], - ) - for r in recommendations - ] - except Exception as e: - logger.error(f"Failed to get recommendations: {e}") - - # Fallback: recommend demo unit - return [ - RecommendedUnit( - unit_id="demo_unit_v1", - template="flight_path", - difficulty="base", - reason="Neu: Noch nicht gespielt", - ) - ] - - -@router.get("/analytics/student/{student_id}") -async def get_student_analytics( - student_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Get unit analytics for a student. - - Includes completion rates, learning gains, time spent. - """ - db = await get_unit_database() - if db: - try: - analytics = await db.get_student_unit_analytics(student_id) - return analytics - except Exception as e: - logger.error(f"Failed to get analytics: {e}") - - return { - "student_id": student_id, - "units_attempted": 0, - "units_completed": 0, - "avg_completion_rate": 0.0, - "avg_learning_gain": None, - "total_minutes": 0, - } - - -@router.get("/analytics/unit/{unit_id}") -async def get_unit_analytics( - unit_id: str, - user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) -) -> Dict[str, Any]: - """ - Get analytics for a specific unit. - - Shows aggregate performance across all students. - """ - db = await get_unit_database() - if db: - try: - analytics = await db.get_unit_performance(unit_id) - return analytics - except Exception as e: - logger.error(f"Failed to get unit analytics: {e}") - - return { - "unit_id": unit_id, - "total_sessions": 0, - "completed_sessions": 0, - "completion_percent": 0.0, - "avg_duration_minutes": 0, - "avg_learning_gain": None, - } - - -@router.get("/health") -async def health_check() -> Dict[str, Any]: - """Health check for unit API.""" - db = await get_unit_database() - db_status = "connected" if db else "disconnected" - - return { - "status": "healthy", - "service": "breakpilot-units", - "database": db_status, - "auth_required": REQUIRE_AUTH, - } +# Backward-compat shim -- module moved to units/routes.py +import importlib as _importlib +import sys as _sys +_sys.modules[__name__] = _importlib.import_module("units.routes") diff --git a/backend-lehrer/units/__init__.py b/backend-lehrer/units/__init__.py new file mode 100644 index 0000000..9fc6f42 --- /dev/null +++ b/backend-lehrer/units/__init__.py @@ -0,0 +1 @@ +# units — Learning units, analytics, definitions, content generation. diff --git a/backend-lehrer/units/analytics_api.py b/backend-lehrer/units/analytics_api.py new file mode 100644 index 0000000..cab984a --- /dev/null +++ b/backend-lehrer/units/analytics_api.py @@ -0,0 +1,25 @@ +""" +Breakpilot Drive - Unit Analytics API — Barrel Re-export. + +Erweiterte Analytics fuer Lernfortschritt: +- Pre/Post Gain Visualisierung +- Misconception-Tracking +- Stop-Level Analytics +- Aggregierte Klassen-Statistiken +- Export-Funktionen + +Split into: + - unit_analytics_models.py: Pydantic models & enums + - unit_analytics_helpers.py: Database access & computation helpers + - unit_analytics_routes.py: Core analytics endpoint handlers + - unit_analytics_export.py: Export & dashboard endpoints +""" + +from fastapi import APIRouter + +from .analytics_routes import router as _routes_router +from .analytics_export import router as _export_router + +router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"]) +router.include_router(_routes_router) +router.include_router(_export_router) diff --git a/backend-lehrer/units/analytics_export.py b/backend-lehrer/units/analytics_export.py new file mode 100644 index 0000000..6012723 --- /dev/null +++ b/backend-lehrer/units/analytics_export.py @@ -0,0 +1,145 @@ +""" +Unit Analytics API - Export & Dashboard Routes. + +Export endpoints for learning gains and misconceptions, plus dashboard overview. +""" + +import logging +from datetime import datetime +from typing import Optional, Dict, Any + +from fastapi import APIRouter, Query +from fastapi.responses import Response + +from .analytics_models import TimeRange, ExportFormat +from .analytics_helpers import get_analytics_database + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Unit Analytics"]) + + +# ============================================== +# API Endpoints - Export +# ============================================== + +@router.get("/export/learning-gains") +async def export_learning_gains( + unit_id: Optional[str] = Query(None), + class_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.ALL), + format: ExportFormat = Query(ExportFormat.JSON), +) -> Any: + """ + Export learning gain data. + """ + db = await get_analytics_database() + data = [] + + if db: + try: + data = await db.export_learning_gains( + unit_id=unit_id, class_id=class_id, time_range=time_range.value + ) + except Exception as e: + logger.error(f"Failed to export data: {e}") + + if format == ExportFormat.CSV: + if not data: + csv_content = "student_id,unit_id,precheck,postcheck,gain\n" + else: + csv_content = "student_id,unit_id,precheck,postcheck,gain\n" + for row in data: + csv_content += f"{row['student_id']},{row['unit_id']},{row.get('precheck', '')},{row.get('postcheck', '')},{row.get('gain', '')}\n" + + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=learning_gains.csv"} + ) + + return { + "export_date": datetime.utcnow().isoformat(), + "filters": { + "unit_id": unit_id, "class_id": class_id, "time_range": time_range.value, + }, + "data": data, + } + + +@router.get("/export/misconceptions") +async def export_misconceptions( + class_id: Optional[str] = Query(None), + format: ExportFormat = Query(ExportFormat.JSON), +) -> Any: + """ + Export misconception data for further analysis. + """ + # Import here to avoid circular dependency + from .analytics_routes import get_misconception_report + + report = await get_misconception_report( + class_id=class_id, unit_id=None, + time_range=TimeRange.MONTH, limit=100 + ) + + if format == ExportFormat.CSV: + csv_content = "concept_id,concept_label,misconception,frequency,unit_id,stop_id\n" + for m in report.most_common: + csv_content += f'"{m.concept_id}","{m.concept_label}","{m.misconception_text}",{m.frequency},"{m.unit_id}","{m.stop_id}"\n' + + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=misconceptions.csv"} + ) + + return { + "export_date": datetime.utcnow().isoformat(), + "class_id": class_id, + "total_entries": len(report.most_common), + "data": [m.model_dump() for m in report.most_common], + } + + +# ============================================== +# API Endpoints - Dashboard Aggregates +# ============================================== + +@router.get("/dashboard/overview") +async def get_analytics_overview( + time_range: TimeRange = Query(TimeRange.MONTH), +) -> Dict[str, Any]: + """ + Get high-level analytics overview for dashboard. + """ + db = await get_analytics_database() + + if db: + try: + overview = await db.get_analytics_overview(time_range.value) + return overview + except Exception as e: + logger.error(f"Failed to get analytics overview: {e}") + + return { + "time_range": time_range.value, + "total_sessions": 0, + "unique_students": 0, + "avg_completion_rate": 0.0, + "avg_learning_gain": 0.0, + "most_played_units": [], + "struggling_concepts": [], + "active_classes": 0, + } + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """Health check for analytics API.""" + db = await get_analytics_database() + return { + "status": "healthy", + "service": "unit-analytics", + "database": "connected" if db else "disconnected", + } diff --git a/backend-lehrer/units/analytics_helpers.py b/backend-lehrer/units/analytics_helpers.py new file mode 100644 index 0000000..91202c1 --- /dev/null +++ b/backend-lehrer/units/analytics_helpers.py @@ -0,0 +1,97 @@ +""" +Unit Analytics API - Helpers. + +Database access, statistical computation, and utility functions. +""" + +import os +import logging +from typing import List, Dict, Optional + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" + +# Database singleton +_analytics_db = None + + +async def get_analytics_database(): + """Get analytics database instance.""" + global _analytics_db + if not USE_DATABASE: + return None + if _analytics_db is None: + try: + from unit.database import get_analytics_db + _analytics_db = await get_analytics_db() + logger.info("Analytics database initialized") + except ImportError: + logger.warning("Analytics database module not available") + except Exception as e: + logger.warning(f"Analytics database not available: {e}") + return _analytics_db + + +def calculate_gain_distribution(gains: List[float]) -> Dict[str, int]: + """Calculate distribution of learning gains into buckets.""" + distribution = { + "< -20%": 0, + "-20% to -10%": 0, + "-10% to 0%": 0, + "0% to 10%": 0, + "10% to 20%": 0, + "> 20%": 0, + } + + for gain in gains: + gain_percent = gain * 100 + if gain_percent < -20: + distribution["< -20%"] += 1 + elif gain_percent < -10: + distribution["-20% to -10%"] += 1 + elif gain_percent < 0: + distribution["-10% to 0%"] += 1 + elif gain_percent < 10: + distribution["0% to 10%"] += 1 + elif gain_percent < 20: + distribution["10% to 20%"] += 1 + else: + distribution["> 20%"] += 1 + + return distribution + + +def calculate_trend(scores: List[float]) -> str: + """Calculate trend from a series of scores.""" + if len(scores) < 3: + return "insufficient_data" + + # Simple linear regression + n = len(scores) + x_mean = (n - 1) / 2 + y_mean = sum(scores) / n + + numerator = sum((i - x_mean) * (scores[i] - y_mean) for i in range(n)) + denominator = sum((i - x_mean) ** 2 for i in range(n)) + + if denominator == 0: + return "stable" + + slope = numerator / denominator + + if slope > 0.05: + return "improving" + elif slope < -0.05: + return "declining" + else: + return "stable" + + +def calculate_difficulty_rating(success_rate: float, avg_attempts: float) -> float: + """Calculate difficulty rating 1-5 based on success metrics.""" + # Lower success rate and higher attempts = higher difficulty + base_difficulty = (1 - success_rate) * 3 + 1 # 1-4 range + attempt_modifier = min(avg_attempts - 1, 1) # 0-1 range + return min(5.0, base_difficulty + attempt_modifier) diff --git a/backend-lehrer/units/analytics_models.py b/backend-lehrer/units/analytics_models.py new file mode 100644 index 0000000..5a63938 --- /dev/null +++ b/backend-lehrer/units/analytics_models.py @@ -0,0 +1,127 @@ +""" +Unit Analytics API - Pydantic Models. + +Data models for learning gains, stop performance, misconceptions, +student progress, class comparison, and export. +""" + +from typing import List, Optional, Dict, Any +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + + +class TimeRange(str, Enum): + """Time range for analytics queries""" + WEEK = "week" + MONTH = "month" + QUARTER = "quarter" + ALL = "all" + + +class LearningGainData(BaseModel): + """Pre/Post learning gain data point""" + student_id: str + student_name: str + unit_id: str + precheck_score: float + postcheck_score: float + learning_gain: float + percentile: Optional[float] = None + + +class LearningGainSummary(BaseModel): + """Aggregated learning gain statistics""" + unit_id: str + unit_title: str + total_students: int + avg_precheck: float + avg_postcheck: float + avg_gain: float + median_gain: float + std_deviation: float + positive_gain_count: int + negative_gain_count: int + no_change_count: int + gain_distribution: Dict[str, int] + individual_gains: List[LearningGainData] + + +class StopPerformance(BaseModel): + """Performance data for a single stop""" + stop_id: str + stop_label: str + attempts_total: int + success_rate: float + avg_time_seconds: float + avg_attempts_before_success: float + common_errors: List[str] + difficulty_rating: float # 1-5 based on performance + + +class UnitPerformanceDetail(BaseModel): + """Detailed unit performance breakdown""" + unit_id: str + unit_title: str + template: str + total_sessions: int + completed_sessions: int + completion_rate: float + avg_duration_minutes: float + stops: List[StopPerformance] + bottleneck_stops: List[str] # Stops where students struggle most + + +class MisconceptionEntry(BaseModel): + """Individual misconception tracking""" + concept_id: str + concept_label: str + misconception_text: str + frequency: int + affected_student_ids: List[str] + unit_id: str + stop_id: str + detected_via: str # "precheck", "postcheck", "interaction" + first_detected: datetime + last_detected: datetime + + +class MisconceptionReport(BaseModel): + """Comprehensive misconception report""" + class_id: Optional[str] + time_range: str + total_misconceptions: int + unique_concepts: int + most_common: List[MisconceptionEntry] + by_unit: Dict[str, List[MisconceptionEntry]] + trending_up: List[MisconceptionEntry] # Getting more frequent + resolved: List[MisconceptionEntry] # No longer appearing + + +class StudentProgressTimeline(BaseModel): + """Timeline of student progress""" + student_id: str + student_name: str + units_completed: int + total_time_minutes: int + avg_score: float + trend: str # "improving", "stable", "declining" + timeline: List[Dict[str, Any]] # List of session events + + +class ClassComparisonData(BaseModel): + """Data for comparing class performance""" + class_id: str + class_name: str + student_count: int + units_assigned: int + avg_completion_rate: float + avg_learning_gain: float + avg_time_per_unit: float + + +class ExportFormat(str, Enum): + """Export format options""" + JSON = "json" + CSV = "csv" diff --git a/backend-lehrer/units/analytics_routes.py b/backend-lehrer/units/analytics_routes.py new file mode 100644 index 0000000..de9c714 --- /dev/null +++ b/backend-lehrer/units/analytics_routes.py @@ -0,0 +1,394 @@ +""" +Unit Analytics API - Routes. + +All API endpoints for learning gain, stop-level, misconception, +student timeline, class comparison, export, and dashboard analytics. +""" + +import logging +import statistics +from datetime import datetime +from typing import Optional, Dict, Any, List + +from fastapi import APIRouter, Query + +from .analytics_models import ( + TimeRange, + LearningGainData, + LearningGainSummary, + StopPerformance, + UnitPerformanceDetail, + MisconceptionEntry, + MisconceptionReport, + StudentProgressTimeline, + ClassComparisonData, +) +from .analytics_helpers import ( + get_analytics_database, + calculate_gain_distribution, + calculate_trend, + calculate_difficulty_rating, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Unit Analytics"]) + + +# ============================================== +# API Endpoints - Learning Gain +# ============================================== + +# NOTE: Static routes must come BEFORE dynamic routes like /{unit_id} +@router.get("/learning-gain/compare") +async def compare_learning_gains( + unit_ids: str = Query(..., description="Comma-separated unit IDs"), + class_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.MONTH), +) -> Dict[str, Any]: + """ + Compare learning gains across multiple units. + """ + unit_list = [u.strip() for u in unit_ids.split(",")] + comparisons = [] + + for unit_id in unit_list: + try: + summary = await get_learning_gain_analysis(unit_id, class_id, time_range) + comparisons.append({ + "unit_id": unit_id, + "avg_gain": summary.avg_gain, + "median_gain": summary.median_gain, + "total_students": summary.total_students, + "positive_rate": summary.positive_gain_count / max(summary.total_students, 1), + }) + except Exception as e: + logger.error(f"Failed to get comparison for {unit_id}: {e}") + + return { + "time_range": time_range.value, + "class_id": class_id, + "comparisons": sorted(comparisons, key=lambda x: x["avg_gain"], reverse=True), + } + + +@router.get("/learning-gain/{unit_id}", response_model=LearningGainSummary) +async def get_learning_gain_analysis( + unit_id: str, + class_id: Optional[str] = Query(None, description="Filter by class"), + time_range: TimeRange = Query(TimeRange.MONTH, description="Time range for analysis"), +) -> LearningGainSummary: + """ + Get detailed pre/post learning gain analysis for a unit. + """ + db = await get_analytics_database() + individual_gains = [] + + if db: + try: + sessions = await db.get_unit_sessions_with_scores( + unit_id=unit_id, + class_id=class_id, + time_range=time_range.value + ) + + for session in sessions: + if session.get("precheck_score") is not None and session.get("postcheck_score") is not None: + gain = session["postcheck_score"] - session["precheck_score"] + individual_gains.append(LearningGainData( + student_id=session["student_id"], + student_name=session.get("student_name", session["student_id"][:8]), + unit_id=unit_id, + precheck_score=session["precheck_score"], + postcheck_score=session["postcheck_score"], + learning_gain=gain, + )) + except Exception as e: + logger.error(f"Failed to get learning gain data: {e}") + + # Calculate statistics + if not individual_gains: + return LearningGainSummary( + unit_id=unit_id, + unit_title=f"Unit {unit_id}", + total_students=0, + avg_precheck=0.0, avg_postcheck=0.0, + avg_gain=0.0, median_gain=0.0, std_deviation=0.0, + positive_gain_count=0, negative_gain_count=0, no_change_count=0, + gain_distribution={}, individual_gains=[], + ) + + gains = [g.learning_gain for g in individual_gains] + prechecks = [g.precheck_score for g in individual_gains] + postchecks = [g.postcheck_score for g in individual_gains] + + avg_gain = statistics.mean(gains) + median_gain = statistics.median(gains) + std_dev = statistics.stdev(gains) if len(gains) > 1 else 0.0 + + # Calculate percentiles + sorted_gains = sorted(gains) + for data in individual_gains: + rank = sorted_gains.index(data.learning_gain) + 1 + data.percentile = rank / len(sorted_gains) * 100 + + return LearningGainSummary( + unit_id=unit_id, + unit_title=f"Unit {unit_id}", + total_students=len(individual_gains), + avg_precheck=statistics.mean(prechecks), + avg_postcheck=statistics.mean(postchecks), + avg_gain=avg_gain, + median_gain=median_gain, + std_deviation=std_dev, + positive_gain_count=sum(1 for g in gains if g > 0.01), + negative_gain_count=sum(1 for g in gains if g < -0.01), + no_change_count=sum(1 for g in gains if -0.01 <= g <= 0.01), + gain_distribution=calculate_gain_distribution(gains), + individual_gains=sorted(individual_gains, key=lambda x: x.learning_gain, reverse=True), + ) + + +# ============================================== +# API Endpoints - Stop-Level Analytics +# ============================================== + +@router.get("/unit/{unit_id}/stops", response_model=UnitPerformanceDetail) +async def get_unit_stop_analytics( + unit_id: str, + class_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.MONTH), +) -> UnitPerformanceDetail: + """ + Get detailed stop-level performance analytics. + """ + db = await get_analytics_database() + stops_data = [] + + if db: + try: + stop_stats = await db.get_stop_performance( + unit_id=unit_id, class_id=class_id, time_range=time_range.value + ) + + for stop in stop_stats: + difficulty = calculate_difficulty_rating( + stop.get("success_rate", 0.5), + stop.get("avg_attempts", 1.0) + ) + stops_data.append(StopPerformance( + stop_id=stop["stop_id"], + stop_label=stop.get("stop_label", stop["stop_id"]), + attempts_total=stop.get("total_attempts", 0), + success_rate=stop.get("success_rate", 0.0), + avg_time_seconds=stop.get("avg_time_seconds", 0.0), + avg_attempts_before_success=stop.get("avg_attempts", 1.0), + common_errors=stop.get("common_errors", []), + difficulty_rating=difficulty, + )) + + unit_stats = await db.get_unit_overall_stats(unit_id, class_id, time_range.value) + except Exception as e: + logger.error(f"Failed to get stop analytics: {e}") + unit_stats = {} + else: + unit_stats = {} + + # Identify bottleneck stops + bottlenecks = [ + s.stop_id for s in stops_data + if s.difficulty_rating > 3.5 or s.success_rate < 0.6 + ] + + return UnitPerformanceDetail( + unit_id=unit_id, + unit_title=f"Unit {unit_id}", + template=unit_stats.get("template", "unknown"), + total_sessions=unit_stats.get("total_sessions", 0), + completed_sessions=unit_stats.get("completed_sessions", 0), + completion_rate=unit_stats.get("completion_rate", 0.0), + avg_duration_minutes=unit_stats.get("avg_duration_minutes", 0.0), + stops=stops_data, + bottleneck_stops=bottlenecks, + ) + + +# ============================================== +# API Endpoints - Misconception Tracking +# ============================================== + +@router.get("/misconceptions", response_model=MisconceptionReport) +async def get_misconception_report( + class_id: Optional[str] = Query(None), + unit_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.MONTH), + limit: int = Query(20, ge=1, le=100), +) -> MisconceptionReport: + """ + Get comprehensive misconception report. + """ + db = await get_analytics_database() + misconceptions = [] + + if db: + try: + raw_misconceptions = await db.get_misconceptions( + class_id=class_id, unit_id=unit_id, + time_range=time_range.value, limit=limit + ) + + for m in raw_misconceptions: + misconceptions.append(MisconceptionEntry( + concept_id=m["concept_id"], + concept_label=m["concept_label"], + misconception_text=m["misconception_text"], + frequency=m["frequency"], + affected_student_ids=m.get("student_ids", []), + unit_id=m["unit_id"], + stop_id=m["stop_id"], + detected_via=m.get("detected_via", "unknown"), + first_detected=m.get("first_detected", datetime.utcnow()), + last_detected=m.get("last_detected", datetime.utcnow()), + )) + except Exception as e: + logger.error(f"Failed to get misconceptions: {e}") + + # Group by unit + by_unit = {} + for m in misconceptions: + if m.unit_id not in by_unit: + by_unit[m.unit_id] = [] + by_unit[m.unit_id].append(m) + + trending_up = misconceptions[:3] if misconceptions else [] + resolved = [] + + return MisconceptionReport( + class_id=class_id, + time_range=time_range.value, + total_misconceptions=sum(m.frequency for m in misconceptions), + unique_concepts=len(set(m.concept_id for m in misconceptions)), + most_common=sorted(misconceptions, key=lambda x: x.frequency, reverse=True)[:10], + by_unit=by_unit, + trending_up=trending_up, + resolved=resolved, + ) + + +@router.get("/misconceptions/student/{student_id}") +async def get_student_misconceptions( + student_id: str, + time_range: TimeRange = Query(TimeRange.ALL), +) -> Dict[str, Any]: + """ + Get misconceptions for a specific student. + """ + db = await get_analytics_database() + + if db: + try: + misconceptions = await db.get_student_misconceptions( + student_id=student_id, time_range=time_range.value + ) + return { + "student_id": student_id, + "misconceptions": misconceptions, + "recommended_remediation": [ + {"concept": m["concept_label"], "activity": f"Review {m['unit_id']}/{m['stop_id']}"} + for m in misconceptions[:5] + ] + } + except Exception as e: + logger.error(f"Failed to get student misconceptions: {e}") + + return { + "student_id": student_id, + "misconceptions": [], + "recommended_remediation": [], + } + + +# ============================================== +# API Endpoints - Student Progress Timeline +# ============================================== + +@router.get("/student/{student_id}/timeline", response_model=StudentProgressTimeline) +async def get_student_timeline( + student_id: str, + time_range: TimeRange = Query(TimeRange.ALL), +) -> StudentProgressTimeline: + """ + Get detailed progress timeline for a student. + """ + db = await get_analytics_database() + timeline = [] + scores = [] + + if db: + try: + sessions = await db.get_student_sessions( + student_id=student_id, time_range=time_range.value + ) + + for session in sessions: + timeline.append({ + "date": session.get("started_at"), + "unit_id": session.get("unit_id"), + "completed": session.get("completed_at") is not None, + "precheck": session.get("precheck_score"), + "postcheck": session.get("postcheck_score"), + "duration_minutes": session.get("duration_seconds", 0) // 60, + }) + if session.get("postcheck_score") is not None: + scores.append(session["postcheck_score"]) + except Exception as e: + logger.error(f"Failed to get student timeline: {e}") + + trend = calculate_trend(scores) if scores else "insufficient_data" + + return StudentProgressTimeline( + student_id=student_id, + student_name=f"Student {student_id[:8]}", + units_completed=sum(1 for t in timeline if t["completed"]), + total_time_minutes=sum(t["duration_minutes"] for t in timeline), + avg_score=statistics.mean(scores) if scores else 0.0, + trend=trend, + timeline=timeline, + ) + + +# ============================================== +# API Endpoints - Class Comparison +# ============================================== + +@router.get("/compare/classes", response_model=List[ClassComparisonData]) +async def compare_classes( + class_ids: str = Query(..., description="Comma-separated class IDs"), + time_range: TimeRange = Query(TimeRange.MONTH), +) -> List[ClassComparisonData]: + """ + Compare performance across multiple classes. + """ + class_list = [c.strip() for c in class_ids.split(",")] + comparisons = [] + + db = await get_analytics_database() + if db: + for class_id in class_list: + try: + stats = await db.get_class_aggregate_stats(class_id, time_range.value) + comparisons.append(ClassComparisonData( + class_id=class_id, + class_name=stats.get("class_name", f"Klasse {class_id[:8]}"), + student_count=stats.get("student_count", 0), + units_assigned=stats.get("units_assigned", 0), + avg_completion_rate=stats.get("avg_completion_rate", 0.0), + avg_learning_gain=stats.get("avg_learning_gain", 0.0), + avg_time_per_unit=stats.get("avg_time_per_unit", 0.0), + )) + except Exception as e: + logger.error(f"Failed to get stats for class {class_id}: {e}") + + return sorted(comparisons, key=lambda x: x.avg_learning_gain, reverse=True) + + diff --git a/backend-lehrer/units/api.py b/backend-lehrer/units/api.py new file mode 100644 index 0000000..3b2f63a --- /dev/null +++ b/backend-lehrer/units/api.py @@ -0,0 +1,57 @@ +# ============================================== +# Breakpilot Drive - Unit API (barrel re-export) +# ============================================== +# This module was split into: +# - unit_models.py (Pydantic models) +# - unit_helpers.py (Auth, DB, token, validation helpers) +# - unit_routes.py (Definition, session, analytics routes) +# - unit_content_routes.py (H5P, worksheet, PDF routes) +# +# The `router` object is assembled here by including all sub-routers. +# Importers that did `from unit_api import router` continue to work. + +from fastapi import APIRouter + +from .routes import router as _routes_router +from .definition_routes import router as _definition_router +from .content_routes import router as _content_router + +# Re-export models for any direct importers +from .models import ( # noqa: F401 + UnitDefinitionResponse, + CreateSessionRequest, + SessionResponse, + TelemetryEvent, + TelemetryPayload, + TelemetryResponse, + PostcheckAnswer, + CompleteSessionRequest, + SessionSummaryResponse, + UnitListItem, + RecommendedUnit, + CreateUnitRequest, + UpdateUnitRequest, + ValidationError, + ValidationResult, +) + +# Re-export helpers for any direct importers +from .helpers import ( # noqa: F401 + get_optional_current_user, + get_unit_database, + create_session_token, + verify_session_token, + get_session_from_token, + validate_unit_definition, + USE_DATABASE, + REQUIRE_AUTH, + SECRET_KEY, +) + +# Assemble the combined router. +# _routes_router and _content_router both use prefix="/api/units", +# so we create a plain router and include them without extra prefix. +router = APIRouter() +router.include_router(_routes_router) +router.include_router(_definition_router) +router.include_router(_content_router) diff --git a/backend-lehrer/units/content_routes.py b/backend-lehrer/units/content_routes.py new file mode 100644 index 0000000..33daacc --- /dev/null +++ b/backend-lehrer/units/content_routes.py @@ -0,0 +1,160 @@ +# ============================================== +# Breakpilot Drive - Unit Content Generation Routes +# ============================================== +# API endpoints for H5P content, worksheets, and PDF generation. +# Extracted from unit_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends +from typing import Optional, Dict, Any +import logging + +from .models import UnitDefinitionResponse +from .helpers import get_optional_current_user, get_unit_database + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) + + +@router.get("/content/{unit_id}/h5p") +async def generate_h5p_content( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Generate H5P content items for a unit. + + Returns H5P-compatible content structures for: + - Drag and Drop (vocabulary matching) + - Fill in the Blanks (concept texts) + - Multiple Choice (misconception targeting) + """ + from content_generators import generate_h5p_for_unit, H5PGenerator, generate_h5p_manifest + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for H5P generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + generator = H5PGenerator(locale=locale) + contents = generator.generate_from_unit(unit_def) + manifest = generate_h5p_manifest(contents, unit_id) + + return { + "unit_id": unit_id, + "locale": locale, + "generated_count": len(contents), + "manifest": manifest, + "contents": [c.to_h5p_structure() for c in contents] + } + except Exception as e: + logger.error(f"H5P generation failed: {e}") + raise HTTPException(status_code=500, detail=f"H5P generation failed: {str(e)}") + + +@router.get("/content/{unit_id}/worksheet") +async def generate_worksheet_html( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Generate worksheet HTML for a unit. + + Returns HTML that can be: + - Displayed in browser + - Converted to PDF using weasyprint + - Printed directly + """ + from content_generators import PDFGenerator + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for worksheet generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + generator = PDFGenerator(locale=locale) + worksheet = generator.generate_from_unit(unit_def) + + return { + "unit_id": unit_id, + "locale": locale, + "title": worksheet.title, + "sections": len(worksheet.sections), + "html": worksheet.to_html() + } + except Exception as e: + logger.error(f"Worksheet generation failed: {e}") + raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}") + + +@router.get("/content/{unit_id}/worksheet.pdf") +async def download_worksheet_pdf( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +): + """ + Generate and download worksheet as PDF. + + Requires weasyprint to be installed on the server. + """ + from fastapi.responses import Response + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for PDF generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + from content_generators import generate_worksheet_pdf + pdf_bytes = generate_worksheet_pdf(unit_def, locale) + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{unit_id}_worksheet.pdf"' + } + ) + except ImportError: + raise HTTPException( + status_code=501, + detail="PDF generation not available. Install weasyprint: pip install weasyprint" + ) + except Exception as e: + logger.error(f"PDF generation failed: {e}") + raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}") diff --git a/backend-lehrer/units/definition_routes.py b/backend-lehrer/units/definition_routes.py new file mode 100644 index 0000000..32a8733 --- /dev/null +++ b/backend-lehrer/units/definition_routes.py @@ -0,0 +1,301 @@ +# ============================================== +# Breakpilot Drive - Unit Definition CRUD Routes +# ============================================== +# Endpoints for creating, updating, deleting, and validating +# unit definitions. Extracted from unit_routes.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends +from typing import Optional, Dict, Any +from datetime import datetime +import logging + +from .models import ( + UnitDefinitionResponse, + CreateUnitRequest, + UpdateUnitRequest, + ValidationResult, +) +from .helpers import ( + get_optional_current_user, + get_unit_database, + validate_unit_definition, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) + + +@router.post("/definitions", response_model=UnitDefinitionResponse) +async def create_unit_definition( + request_data: CreateUnitRequest, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Create a new unit definition. + + - Validates unit structure + - Saves to database or JSON file + - Returns created unit + """ + import json + from pathlib import Path + + # Build full definition + definition = { + "unit_id": request_data.unit_id, + "template": request_data.template, + "version": request_data.version, + "locale": request_data.locale, + "grade_band": request_data.grade_band, + "duration_minutes": request_data.duration_minutes, + "difficulty": request_data.difficulty, + "subject": request_data.subject, + "topic": request_data.topic, + "learning_objectives": request_data.learning_objectives, + "stops": request_data.stops, + "precheck": request_data.precheck or { + "question_set_id": f"{request_data.unit_id}_precheck", + "required": True, + "time_limit_seconds": 120 + }, + "postcheck": request_data.postcheck or { + "question_set_id": f"{request_data.unit_id}_postcheck", + "required": True, + "time_limit_seconds": 180 + }, + "teacher_controls": request_data.teacher_controls or { + "allow_skip": True, + "allow_replay": True, + "max_time_per_stop_sec": 90, + "show_hints": True, + "require_precheck": True, + "require_postcheck": True + }, + "assets": request_data.assets or {}, + "metadata": request_data.metadata or { + "author": user.get("email", "Unknown") if user else "Unknown", + "created": datetime.utcnow().isoformat(), + "curriculum_reference": "" + } + } + + # Validate + validation = validate_unit_definition(definition) + if not validation.valid: + error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] + raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") + + # Check if unit_id already exists + db = await get_unit_database() + if db: + try: + existing = await db.get_unit_definition(request_data.unit_id) + if existing: + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + + # Save to database + await db.create_unit_definition( + unit_id=request_data.unit_id, + template=request_data.template, + version=request_data.version, + locale=request_data.locale, + grade_band=request_data.grade_band, + duration_minutes=request_data.duration_minutes, + difficulty=request_data.difficulty, + definition=definition, + status=request_data.status + ) + logger.info(f"Unit created in database: {request_data.unit_id}") + except HTTPException: + raise + except Exception as e: + logger.warning(f"Database save failed, using JSON fallback: {e}") + # Fallback to JSON + units_dir = Path(__file__).parent / "data" / "units" + units_dir.mkdir(parents=True, exist_ok=True) + json_path = units_dir / f"{request_data.unit_id}.json" + if json_path.exists(): + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit created as JSON: {json_path}") + else: + # JSON only mode + units_dir = Path(__file__).parent / "data" / "units" + units_dir.mkdir(parents=True, exist_ok=True) + json_path = units_dir / f"{request_data.unit_id}.json" + if json_path.exists(): + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit created as JSON: {json_path}") + + return UnitDefinitionResponse( + unit_id=request_data.unit_id, + template=request_data.template, + version=request_data.version, + locale=request_data.locale, + grade_band=request_data.grade_band, + duration_minutes=request_data.duration_minutes, + difficulty=request_data.difficulty, + definition=definition + ) + + +@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse) +async def update_unit_definition( + unit_id: str, + request_data: UpdateUnitRequest, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Update an existing unit definition. + + - Merges updates with existing definition + - Re-validates + - Saves updated version + """ + import json + from pathlib import Path + + # Get existing unit + db = await get_unit_database() + existing = None + + if db: + try: + existing = await db.get_unit_definition(unit_id) + except Exception as e: + logger.warning(f"Database read failed: {e}") + + if not existing: + # Try JSON file + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + if json_path.exists(): + with open(json_path, "r", encoding="utf-8") as f: + file_data = json.load(f) + existing = { + "unit_id": file_data.get("unit_id"), + "template": file_data.get("template"), + "version": file_data.get("version", "1.0.0"), + "locale": file_data.get("locale", ["de-DE"]), + "grade_band": file_data.get("grade_band", []), + "duration_minutes": file_data.get("duration_minutes", 8), + "difficulty": file_data.get("difficulty", "base"), + "definition": file_data + } + + if not existing: + raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") + + # Merge updates into existing definition + definition = existing.get("definition", {}) + update_dict = request_data.model_dump(exclude_unset=True) + + for key, value in update_dict.items(): + if value is not None: + definition[key] = value + + # Validate updated definition + validation = validate_unit_definition(definition) + if not validation.valid: + error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] + raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") + + # Save + if db: + try: + await db.update_unit_definition( + unit_id=unit_id, + version=definition.get("version"), + locale=definition.get("locale"), + grade_band=definition.get("grade_band"), + duration_minutes=definition.get("duration_minutes"), + difficulty=definition.get("difficulty"), + definition=definition, + status=update_dict.get("status") + ) + logger.info(f"Unit updated in database: {unit_id}") + except Exception as e: + logger.warning(f"Database update failed, using JSON: {e}") + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + else: + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit updated as JSON: {json_path}") + + return UnitDefinitionResponse( + unit_id=unit_id, + template=definition.get("template", existing.get("template")), + version=definition.get("version", existing.get("version", "1.0.0")), + locale=definition.get("locale", existing.get("locale", ["de-DE"])), + grade_band=definition.get("grade_band", existing.get("grade_band", [])), + duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)), + difficulty=definition.get("difficulty", existing.get("difficulty", "base")), + definition=definition + ) + + +@router.delete("/definitions/{unit_id}") +async def delete_unit_definition( + unit_id: str, + force: bool = Query(False, description="Force delete even if published"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Delete a unit definition. + + - By default, only drafts can be deleted + - Use force=true to delete published units + """ + from pathlib import Path + + db = await get_unit_database() + deleted = False + + if db: + try: + existing = await db.get_unit_definition(unit_id) + if existing: + status = existing.get("status", "draft") + if status == "published" and not force: + raise HTTPException( + status_code=400, + detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true." + ) + await db.delete_unit_definition(unit_id) + deleted = True + logger.info(f"Unit deleted from database: {unit_id}") + except HTTPException: + raise + except Exception as e: + logger.warning(f"Database delete failed: {e}") + + # Also check JSON file + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + if json_path.exists(): + json_path.unlink() + deleted = True + logger.info(f"Unit JSON deleted: {json_path}") + + if not deleted: + raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") + + return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"} + + +@router.post("/definitions/validate", response_model=ValidationResult) +async def validate_unit( + unit_data: Dict[str, Any], + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> ValidationResult: + """ + Validate a unit definition without saving. + + Returns validation result with errors and warnings. + """ + return validate_unit_definition(unit_data) diff --git a/backend-lehrer/units/helpers.py b/backend-lehrer/units/helpers.py new file mode 100644 index 0000000..ad502e9 --- /dev/null +++ b/backend-lehrer/units/helpers.py @@ -0,0 +1,204 @@ +# ============================================== +# Breakpilot Drive - Unit API Helpers +# ============================================== +# Auth, database, token, and validation helpers for the Unit API. +# Extracted from unit_api.py for file-size compliance. + +from fastapi import HTTPException, Request +from typing import Optional, Dict, Any, List +from datetime import datetime, timedelta +import os +import logging +import jwt + +from .models import ValidationError, ValidationResult + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production") + + +# ============================================== +# Auth Dependency (reuse from game_api) +# ============================================== + +async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: + """Optional auth dependency for Unit API.""" + if not REQUIRE_AUTH: + return None + + try: + from auth import get_current_user + return await get_current_user(request) + except ImportError: + logger.warning("Auth module not available") + return None + except HTTPException: + raise + except Exception as e: + logger.error(f"Auth error: {e}") + raise HTTPException(status_code=401, detail="Authentication failed") + + +# ============================================== +# Database Integration +# ============================================== + +_unit_db = None + +async def get_unit_database(): + """Get unit database instance with lazy initialization.""" + global _unit_db + if not USE_DATABASE: + return None + if _unit_db is None: + try: + from unit.database import get_unit_db + _unit_db = await get_unit_db() + logger.info("Unit database initialized") + except ImportError: + logger.warning("Unit database module not available") + except Exception as e: + logger.warning(f"Unit database not available: {e}") + return _unit_db + + +# ============================================== +# Token Helpers +# ============================================== + +def create_session_token(session_id: str, student_id: str, expires_hours: int = 4) -> str: + """Create a JWT session token for telemetry authentication.""" + payload = { + "session_id": session_id, + "student_id": student_id, + "exp": datetime.utcnow() + timedelta(hours=expires_hours), + "iat": datetime.utcnow(), + } + return jwt.encode(payload, SECRET_KEY, algorithm="HS256") + + +def verify_session_token(token: str) -> Optional[Dict[str, Any]]: + """Verify a session token and return payload.""" + try: + return jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +async def get_session_from_token(request: Request) -> Optional[Dict[str, Any]]: + """Extract and verify session from Authorization header.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + return verify_session_token(token) + + +# ============================================== +# Validation +# ============================================== + +def validate_unit_definition(unit_data: Dict[str, Any]) -> ValidationResult: + """ + Validate a unit definition structure. + + Returns validation result with errors and warnings. + """ + errors: List[ValidationError] = [] + warnings: List[ValidationError] = [] + + # Required fields + if not unit_data.get("unit_id"): + errors.append(ValidationError(field="unit_id", message="unit_id ist erforderlich")) + + if not unit_data.get("template"): + errors.append(ValidationError(field="template", message="template ist erforderlich")) + elif unit_data["template"] not in ["flight_path", "station_loop"]: + errors.append(ValidationError( + field="template", + message="template muss 'flight_path' oder 'station_loop' sein" + )) + + # Validate stops + stops = unit_data.get("stops", []) + if not stops: + errors.append(ValidationError(field="stops", message="Mindestens 1 Stop erforderlich")) + else: + # Check minimum stops for flight_path + if unit_data.get("template") == "flight_path" and len(stops) < 3: + warnings.append(ValidationError( + field="stops", + message="FlightPath sollte mindestens 3 Stops haben", + severity="warning" + )) + + # Validate each stop + stop_ids = set() + for i, stop in enumerate(stops): + if not stop.get("stop_id"): + errors.append(ValidationError( + field=f"stops[{i}].stop_id", + message=f"Stop {i}: stop_id fehlt" + )) + else: + if stop["stop_id"] in stop_ids: + errors.append(ValidationError( + field=f"stops[{i}].stop_id", + message=f"Stop {i}: Doppelte stop_id '{stop['stop_id']}'" + )) + stop_ids.add(stop["stop_id"]) + + # Check interaction type + interaction = stop.get("interaction", {}) + if not interaction.get("type"): + errors.append(ValidationError( + field=f"stops[{i}].interaction.type", + message=f"Stop {stop.get('stop_id', i)}: Interaktionstyp fehlt" + )) + elif interaction["type"] not in [ + "aim_and_pass", "slider_adjust", "slider_equivalence", + "sequence_arrange", "toggle_switch", "drag_match", + "error_find", "transfer_apply" + ]: + warnings.append(ValidationError( + field=f"stops[{i}].interaction.type", + message=f"Stop {stop.get('stop_id', i)}: Unbekannter Interaktionstyp '{interaction['type']}'", + severity="warning" + )) + + # Check for label + if not stop.get("label"): + warnings.append(ValidationError( + field=f"stops[{i}].label", + message=f"Stop {stop.get('stop_id', i)}: Label fehlt", + severity="warning" + )) + + # Validate duration + duration = unit_data.get("duration_minutes", 0) + if duration < 3 or duration > 20: + warnings.append(ValidationError( + field="duration_minutes", + message="Dauer sollte zwischen 3 und 20 Minuten liegen", + severity="warning" + )) + + # Validate difficulty + if unit_data.get("difficulty") and unit_data["difficulty"] not in ["base", "advanced"]: + warnings.append(ValidationError( + field="difficulty", + message="difficulty sollte 'base' oder 'advanced' sein", + severity="warning" + )) + + return ValidationResult( + valid=len(errors) == 0, + errors=errors, + warnings=warnings + ) diff --git a/backend-lehrer/units/learning.py b/backend-lehrer/units/learning.py new file mode 100644 index 0000000..425ad2f --- /dev/null +++ b/backend-lehrer/units/learning.py @@ -0,0 +1,178 @@ +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import List, Dict, Optional +from pathlib import Path +from datetime import datetime +import uuid +import json +import threading + +# Basisverzeichnis für Arbeitsblätter & Lerneinheiten +BASE_DIR = Path.home() / "Arbeitsblaetter" +LEARNING_UNITS_DIR = BASE_DIR / "Lerneinheiten" +LEARNING_UNITS_FILE = LEARNING_UNITS_DIR / "learning_units.json" + +# Thread-Lock, damit Dateizugriffe sicher bleiben +_lock = threading.Lock() + + +class LearningUnitBase(BaseModel): + title: str = Field(..., description="Titel der Lerneinheit, z.B. 'Das Auge – Klasse 7'") + description: Optional[str] = Field(None, description="Freitext-Beschreibung") + topic: Optional[str] = Field(None, description="Kurz-Thema, z.B. 'Auge'") + grade_level: Optional[str] = Field(None, description="Klassenstufe, z.B. '7'") + language: Optional[str] = Field("de", description="Hauptsprache der Lerneinheit (z.B. 'de', 'tr')") + worksheet_files: List[str] = Field( + default_factory=list, + description="Liste der zugeordneten Arbeitsblatt-Dateien (Basenames oder Pfade)" + ) + status: str = Field( + "raw", + description="Pipeline-Status: raw, cleaned, qa_generated, mc_generated, cloze_generated" + ) + + +class LearningUnitCreate(LearningUnitBase): + """Payload zum Erstellen einer neuen Lerneinheit.""" + pass + + +class LearningUnitUpdate(BaseModel): + """Teil-Update für eine Lerneinheit.""" + title: Optional[str] = None + description: Optional[str] = None + topic: Optional[str] = None + grade_level: Optional[str] = None + language: Optional[str] = None + worksheet_files: Optional[List[str]] = None + status: Optional[str] = None + + +class LearningUnit(LearningUnitBase): + id: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_dict(cls, data: Dict) -> "LearningUnit": + data = data.copy() + if isinstance(data.get("created_at"), str): + data["created_at"] = datetime.fromisoformat(data["created_at"]) + if isinstance(data.get("updated_at"), str): + data["updated_at"] = datetime.fromisoformat(data["updated_at"]) + return cls(**data) + + def to_dict(self) -> Dict: + d = self.dict() + d["created_at"] = self.created_at.isoformat() + d["updated_at"] = self.updated_at.isoformat() + return d + + +def _ensure_storage(): + """Sorgt dafür, dass der Ordner und die JSON-Datei existieren.""" + LEARNING_UNITS_DIR.mkdir(parents=True, exist_ok=True) + if not LEARNING_UNITS_FILE.exists(): + with LEARNING_UNITS_FILE.open("w", encoding="utf-8") as f: + json.dump({}, f) + + +def _load_all_units() -> Dict[str, Dict]: + _ensure_storage() + with LEARNING_UNITS_FILE.open("r", encoding="utf-8") as f: + try: + data = json.load(f) + if not isinstance(data, dict): + return {} + return data + except json.JSONDecodeError: + return {} + + +def _save_all_units(raw: Dict[str, Dict]) -> None: + _ensure_storage() + with LEARNING_UNITS_FILE.open("w", encoding="utf-8") as f: + json.dump(raw, f, ensure_ascii=False, indent=2) + + +def list_learning_units() -> List[LearningUnit]: + with _lock: + raw = _load_all_units() + return [LearningUnit.from_dict(v) for v in raw.values()] + + +def get_learning_unit(unit_id: str) -> Optional[LearningUnit]: + with _lock: + raw = _load_all_units() + data = raw.get(unit_id) + if not data: + return None + return LearningUnit.from_dict(data) + + +def create_learning_unit(payload: LearningUnitCreate) -> LearningUnit: + now = datetime.utcnow() + lu = LearningUnit( + id=str(uuid.uuid4()), + created_at=now, + updated_at=now, + **payload.dict() + ) + with _lock: + raw = _load_all_units() + raw[lu.id] = lu.to_dict() + _save_all_units(raw) + return lu + + +def update_learning_unit(unit_id: str, payload: LearningUnitUpdate) -> Optional[LearningUnit]: + with _lock: + raw = _load_all_units() + existing = raw.get(unit_id) + if not existing: + return None + + lu = LearningUnit.from_dict(existing) + update_data = payload.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(lu, field, value) + + lu.updated_at = datetime.utcnow() + raw[lu.id] = lu.to_dict() + _save_all_units(raw) + return lu + + +def delete_learning_unit(unit_id: str) -> bool: + with _lock: + raw = _load_all_units() + if unit_id not in raw: + return False + del raw[unit_id] + _save_all_units(raw) + return True + + +def attach_worksheets(unit_id: str, worksheet_files: List[str]) -> Optional[LearningUnit]: + """ + Hängt eine Liste von Arbeitsblatt-Dateien an eine bestehende Lerneinheit an. + Doppelte Einträge werden vermieden. + """ + with _lock: + raw = _load_all_units() + existing = raw.get(unit_id) + if not existing: + return None + + lu = LearningUnit.from_dict(existing) + current_set = set(lu.worksheet_files) + for f in worksheet_files: + current_set.add(f) + lu.worksheet_files = sorted(current_set) + lu.updated_at = datetime.utcnow() + + raw[lu.id] = lu.to_dict() + _save_all_units(raw) + return lu + diff --git a/backend-lehrer/units/learning_api.py b/backend-lehrer/units/learning_api.py new file mode 100644 index 0000000..a5afc5a --- /dev/null +++ b/backend-lehrer/units/learning_api.py @@ -0,0 +1,376 @@ +from typing import List, Dict, Any, Optional +from datetime import datetime +from pathlib import Path +import json +import os +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from .learning import ( + LearningUnit, + LearningUnitCreate, + LearningUnitUpdate, + list_learning_units, + get_learning_unit, + create_learning_unit, + update_learning_unit, + delete_learning_unit, +) + +logger = logging.getLogger(__name__) + + +router = APIRouter( + prefix="/learning-units", + tags=["learning-units"], +) + + +# ---------- Payload-Modelle für das Frontend ---------- + + +class LearningUnitCreatePayload(BaseModel): + """ + Payload so, wie er aus dem Frontend kommt: + { + "student": "...", + "subject": "...", + "title": "...", + "grade": "7a" + } + """ + student: Optional[str] = None + subject: Optional[str] = None + title: Optional[str] = None + grade: Optional[str] = None + + +class AttachWorksheetsPayload(BaseModel): + worksheet_files: List[str] + + +class RemoveWorksheetPayload(BaseModel): + worksheet_file: str + + +class GenerateFromAnalysisPayload(BaseModel): + analysis_data: Dict[str, Any] + num_questions: int = 8 + + +# ---------- Hilfsfunktion: Backend-Modell -> Frontend-Objekt ---------- + + +def unit_to_frontend_dict(lu: LearningUnit) -> Dict[str, Any]: + """ + Wandelt eine LearningUnit in das Format um, das das Frontend erwartet. + Wichtig sind: + - id + - label (sichtbarer Name) + - meta (Untertitelzeile) + - worksheet_files (Liste von Dateinamen) + """ + label = lu.title or "Lerneinheit" + + # Meta-Text: z.B. "Thema: Auge · Klasse: 7a · angelegt am 10.12.2025" + meta_parts: List[str] = [] + if lu.topic: + meta_parts.append(f"Thema: {lu.topic}") + if lu.grade_level: + meta_parts.append(f"Klasse: {lu.grade_level}") + created_str = lu.created_at.strftime("%d.%m.%Y") + meta_parts.append(f"angelegt am {created_str}") + + meta = " · ".join(meta_parts) + + return { + "id": lu.id, + "label": label, + "meta": meta, + "title": lu.title, + "topic": lu.topic, + "grade_level": lu.grade_level, + "language": lu.language, + "status": lu.status, + "worksheet_files": lu.worksheet_files, + "created_at": lu.created_at.isoformat(), + "updated_at": lu.updated_at.isoformat(), + } + + +# ---------- Endpunkte ---------- + + +@router.get("/", response_model=List[Dict[str, Any]]) +def api_list_learning_units(): + """Alle Lerneinheiten für das Frontend auflisten.""" + units = list_learning_units() + return [unit_to_frontend_dict(u) for u in units] + + +@router.post("/", response_model=Dict[str, Any]) +def api_create_learning_unit(payload: LearningUnitCreatePayload): + """ + Neue Lerneinheit anlegen. + Mapped das Frontend-Payload (student/subject/title/grade) + auf das generische LearningUnit-Modell. + """ + + # Mindestens eines der Felder muss gesetzt sein + if not (payload.student or payload.subject or payload.title): + raise HTTPException( + status_code=400, + detail="Bitte mindestens Schüler/in, Fach oder Thema angeben.", + ) + + # Titel/Topic bestimmen + # sichtbarer Titel: bevorzugt Thema (title), sonst Kombination + if payload.title: + title = payload.title + else: + parts = [] + if payload.subject: + parts.append(payload.subject) + if payload.student: + parts.append(payload.student) + title = " – ".join(parts) if parts else "Lerneinheit" + + topic = payload.title or payload.subject or None + grade_level = payload.grade or None + + lu_create = LearningUnitCreate( + title=title, + description=None, + topic=topic, + grade_level=grade_level, + language="de", + worksheet_files=[], + status="raw", + ) + + lu = create_learning_unit(lu_create) + return unit_to_frontend_dict(lu) + + +@router.post("/{unit_id}/attach-worksheets", response_model=Dict[str, Any]) +def api_attach_worksheets(unit_id: str, payload: AttachWorksheetsPayload): + """ + Fügt der Lerneinheit eine oder mehrere Arbeitsblätter hinzu. + """ + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + files_to_add = [f for f in payload.worksheet_files if f not in lu.worksheet_files] + if files_to_add: + new_list = lu.worksheet_files + files_to_add + update = LearningUnitUpdate(worksheet_files=new_list) + lu = update_learning_unit(unit_id, update) + if not lu: + raise HTTPException(status_code=500, detail="Lerneinheit konnte nicht aktualisiert werden.") + + return unit_to_frontend_dict(lu) + + +@router.post("/{unit_id}/remove-worksheet", response_model=Dict[str, Any]) +def api_remove_worksheet(unit_id: str, payload: RemoveWorksheetPayload): + """ + Entfernt genau ein Arbeitsblatt aus der Lerneinheit. + """ + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + if payload.worksheet_file not in lu.worksheet_files: + # Nichts zu tun, aber kein Fehler – einfach unverändert zurückgeben + return unit_to_frontend_dict(lu) + + new_list = [f for f in lu.worksheet_files if f != payload.worksheet_file] + update = LearningUnitUpdate(worksheet_files=new_list) + lu = update_learning_unit(unit_id, update) + if not lu: + raise HTTPException(status_code=500, detail="Lerneinheit konnte nicht aktualisiert werden.") + + return unit_to_frontend_dict(lu) + + +@router.delete("/{unit_id}") +def api_delete_learning_unit(unit_id: str): + """ + Lerneinheit komplett löschen (aktuell vom Frontend noch nicht verwendet). + """ + ok = delete_learning_unit(unit_id) + if not ok: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + return {"status": "deleted", "id": unit_id} + + +# ---------- Generator-Endpunkte ---------- + +LERNEINHEITEN_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten") + + +def _save_analysis_and_get_path(unit_id: str, analysis_data: Dict[str, Any]) -> Path: + """Save analysis_data to disk and return the path.""" + os.makedirs(LERNEINHEITEN_DIR, exist_ok=True) + path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_analyse.json" + with open(path, "w", encoding="utf-8") as f: + json.dump(analysis_data, f, ensure_ascii=False, indent=2) + return path + + +@router.post("/{unit_id}/generate-qa") +def api_generate_qa(unit_id: str, payload: GenerateFromAnalysisPayload): + """Generate Q&A items with Leitner fields from analysis data.""" + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data) + + try: + from ai_processing.qa_generator import generate_qa_from_analysis + qa_path = generate_qa_from_analysis(analysis_path, num_questions=payload.num_questions) + with open(qa_path, "r", encoding="utf-8") as f: + qa_data = json.load(f) + + # Update unit status + update_learning_unit(unit_id, LearningUnitUpdate(status="qa_generated")) + logger.info(f"Generated QA for unit {unit_id}: {len(qa_data.get('qa_items', []))} items") + return qa_data + except Exception as e: + logger.error(f"QA generation failed for {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"QA-Generierung fehlgeschlagen: {e}") + + +@router.post("/{unit_id}/generate-mc") +def api_generate_mc(unit_id: str, payload: GenerateFromAnalysisPayload): + """Generate multiple choice questions from analysis data.""" + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data) + + try: + from ai_processing.mc_generator import generate_mc_from_analysis + mc_path = generate_mc_from_analysis(analysis_path, num_questions=payload.num_questions) + with open(mc_path, "r", encoding="utf-8") as f: + mc_data = json.load(f) + + update_learning_unit(unit_id, LearningUnitUpdate(status="mc_generated")) + logger.info(f"Generated MC for unit {unit_id}: {len(mc_data.get('questions', []))} questions") + return mc_data + except Exception as e: + logger.error(f"MC generation failed for {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"MC-Generierung fehlgeschlagen: {e}") + + +@router.post("/{unit_id}/generate-cloze") +def api_generate_cloze(unit_id: str, payload: GenerateFromAnalysisPayload): + """Generate cloze (fill-in-the-blank) items from analysis data.""" + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data) + + try: + from ai_processing.cloze_generator import generate_cloze_from_analysis + cloze_path = generate_cloze_from_analysis(analysis_path) + with open(cloze_path, "r", encoding="utf-8") as f: + cloze_data = json.load(f) + + update_learning_unit(unit_id, LearningUnitUpdate(status="cloze_generated")) + logger.info(f"Generated Cloze for unit {unit_id}: {len(cloze_data.get('cloze_items', []))} items") + return cloze_data + except Exception as e: + logger.error(f"Cloze generation failed for {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"Cloze-Generierung fehlgeschlagen: {e}") + + +@router.get("/{unit_id}/qa") +def api_get_qa(unit_id: str): + """Get generated QA items for a unit.""" + qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json" + if not qa_path.exists(): + raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.") + with open(qa_path, "r", encoding="utf-8") as f: + return json.load(f) + + +@router.get("/{unit_id}/mc") +def api_get_mc(unit_id: str): + """Get generated MC questions for a unit.""" + mc_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_mc.json" + if not mc_path.exists(): + raise HTTPException(status_code=404, detail="Keine MC-Daten gefunden.") + with open(mc_path, "r", encoding="utf-8") as f: + return json.load(f) + + +@router.get("/{unit_id}/cloze") +def api_get_cloze(unit_id: str): + """Get generated cloze items for a unit.""" + cloze_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_cloze.json" + if not cloze_path.exists(): + raise HTTPException(status_code=404, detail="Keine Cloze-Daten gefunden.") + with open(cloze_path, "r", encoding="utf-8") as f: + return json.load(f) + + +@router.post("/{unit_id}/leitner/update") +def api_update_leitner(unit_id: str, item_id: str, correct: bool): + """Update Leitner progress for a QA item.""" + qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json" + if not qa_path.exists(): + raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.") + try: + from ai_processing.qa_generator import update_leitner_progress + result = update_leitner_progress(qa_path, item_id, correct) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{unit_id}/leitner/next") +def api_get_next_review(unit_id: str, limit: int = 5): + """Get next Leitner review items.""" + qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json" + if not qa_path.exists(): + raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.") + try: + from ai_processing.qa_generator import get_next_review_items + items = get_next_review_items(qa_path, limit=limit) + return {"items": items, "count": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class StoryGeneratePayload(BaseModel): + vocabulary: List[Dict[str, Any]] + language: str = "en" + grade_level: str = "5-8" + + +@router.post("/{unit_id}/generate-story") +def api_generate_story(unit_id: str, payload: StoryGeneratePayload): + """Generate a short story using vocabulary words.""" + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + try: + from story_generator import generate_story + result = generate_story( + vocabulary=payload.vocabulary, + language=payload.language, + grade_level=payload.grade_level, + ) + return result + except Exception as e: + logger.error(f"Story generation failed for {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"Story-Generierung fehlgeschlagen: {e}") + diff --git a/backend-lehrer/units/models.py b/backend-lehrer/units/models.py new file mode 100644 index 0000000..dce74b9 --- /dev/null +++ b/backend-lehrer/units/models.py @@ -0,0 +1,149 @@ +# ============================================== +# Breakpilot Drive - Unit API Models +# ============================================== +# Pydantic models for the Unit API. +# Extracted from unit_api.py for file-size compliance. + +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime + + +class UnitDefinitionResponse(BaseModel): + """Unit definition response""" + unit_id: str + template: str + version: str + locale: List[str] + grade_band: List[str] + duration_minutes: int + difficulty: str + definition: Dict[str, Any] + + +class CreateSessionRequest(BaseModel): + """Request to create a unit session""" + unit_id: str + student_id: str + locale: str = "de-DE" + difficulty: str = "base" + + +class SessionResponse(BaseModel): + """Response after creating a session""" + session_id: str + unit_definition_url: str + session_token: str + telemetry_endpoint: str + expires_at: datetime + + +class TelemetryEvent(BaseModel): + """Single telemetry event""" + ts: Optional[str] = None + type: str = Field(..., alias="type") + stop_id: Optional[str] = None + metrics: Optional[Dict[str, Any]] = None + + class Config: + populate_by_name = True + + +class TelemetryPayload(BaseModel): + """Batch telemetry payload""" + session_id: str + events: List[TelemetryEvent] + + +class TelemetryResponse(BaseModel): + """Response after receiving telemetry""" + accepted: int + + +class PostcheckAnswer(BaseModel): + """Single postcheck answer""" + question_id: str + answer: str + + +class CompleteSessionRequest(BaseModel): + """Request to complete a session""" + postcheck_answers: Optional[List[PostcheckAnswer]] = None + + +class SessionSummaryResponse(BaseModel): + """Response with session summary""" + summary: Dict[str, Any] + next_recommendations: Dict[str, Any] + + +class UnitListItem(BaseModel): + """Unit list item""" + unit_id: str + template: str + difficulty: str + duration_minutes: int + locale: List[str] + grade_band: List[str] + + +class RecommendedUnit(BaseModel): + """Recommended unit with reason""" + unit_id: str + template: str + difficulty: str + reason: str + + +class CreateUnitRequest(BaseModel): + """Request to create a new unit definition""" + unit_id: str = Field(..., description="Unique unit identifier") + template: str = Field(..., description="Template type: flight_path or station_loop") + version: str = Field(default="1.0.0", description="Version string") + locale: List[str] = Field(default=["de-DE"], description="Supported locales") + grade_band: List[str] = Field(default=["5", "6", "7"], description="Target grade levels") + duration_minutes: int = Field(default=8, ge=3, le=20, description="Expected duration") + difficulty: str = Field(default="base", description="Difficulty level: base or advanced") + subject: Optional[str] = Field(default=None, description="Subject area") + topic: Optional[str] = Field(default=None, description="Topic within subject") + learning_objectives: List[str] = Field(default=[], description="Learning objectives") + stops: List[Dict[str, Any]] = Field(default=[], description="Unit stops/stations") + precheck: Optional[Dict[str, Any]] = Field(default=None, description="Pre-check configuration") + postcheck: Optional[Dict[str, Any]] = Field(default=None, description="Post-check configuration") + teacher_controls: Optional[Dict[str, Any]] = Field(default=None, description="Teacher control settings") + assets: Optional[Dict[str, Any]] = Field(default=None, description="Asset configuration") + metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata") + status: str = Field(default="draft", description="Publication status: draft or published") + + +class UpdateUnitRequest(BaseModel): + """Request to update an existing unit definition""" + version: Optional[str] = None + locale: Optional[List[str]] = None + grade_band: Optional[List[str]] = None + duration_minutes: Optional[int] = Field(default=None, ge=3, le=20) + difficulty: Optional[str] = None + subject: Optional[str] = None + topic: Optional[str] = None + learning_objectives: Optional[List[str]] = None + stops: Optional[List[Dict[str, Any]]] = None + precheck: Optional[Dict[str, Any]] = None + postcheck: Optional[Dict[str, Any]] = None + teacher_controls: Optional[Dict[str, Any]] = None + assets: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None + status: Optional[str] = None + + +class ValidationError(BaseModel): + """Single validation error""" + field: str + message: str + severity: str = "error" # error or warning + + +class ValidationResult(BaseModel): + """Result of unit validation""" + valid: bool + errors: List[ValidationError] = [] + warnings: List[ValidationError] = [] diff --git a/backend-lehrer/units/routes.py b/backend-lehrer/units/routes.py new file mode 100644 index 0000000..e81e365 --- /dev/null +++ b/backend-lehrer/units/routes.py @@ -0,0 +1,494 @@ +# ============================================== +# Breakpilot Drive - Unit API Routes +# ============================================== +# Endpoints for listing/getting definitions, sessions, telemetry, +# recommendations, and analytics. +# CRUD definition routes are in unit_definition_routes.py. +# Extracted from unit_api.py for file-size compliance. + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +import uuid +import logging + +from .models import ( + UnitDefinitionResponse, + CreateSessionRequest, + SessionResponse, + TelemetryPayload, + TelemetryResponse, + CompleteSessionRequest, + SessionSummaryResponse, + UnitListItem, + RecommendedUnit, +) +from .helpers import ( + get_optional_current_user, + get_unit_database, + create_session_token, + get_session_from_token, + REQUIRE_AUTH, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) + + +# ============================================== +# Definition List/Get Endpoints +# ============================================== + +@router.get("/definitions", response_model=List[UnitListItem]) +async def list_unit_definitions( + template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"), + grade: Optional[str] = Query(None, description="Filter by grade level"), + locale: str = Query("de-DE", description="Filter by locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[UnitListItem]: + """ + List available unit definitions. + + Returns published units matching the filter criteria. + """ + db = await get_unit_database() + if db: + try: + units = await db.list_units( + template=template, + grade=grade, + locale=locale, + published_only=True + ) + return [ + UnitListItem( + unit_id=u["unit_id"], + template=u["template"], + difficulty=u["difficulty"], + duration_minutes=u["duration_minutes"], + locale=u["locale"], + grade_band=u["grade_band"], + ) + for u in units + ] + except Exception as e: + logger.error(f"Failed to list units: {e}") + + # Fallback: return demo unit + return [ + UnitListItem( + unit_id="demo_unit_v1", + template="flight_path", + difficulty="base", + duration_minutes=5, + locale=["de-DE"], + grade_band=["5", "6", "7"], + ) + ] + + +@router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse) +async def get_unit_definition( + unit_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Get a specific unit definition. + + Returns the full unit configuration including stops, interactions, etc. + """ + db = await get_unit_database() + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + return UnitDefinitionResponse( + unit_id=unit["unit_id"], + template=unit["template"], + version=unit["version"], + locale=unit["locale"], + grade_band=unit["grade_band"], + duration_minutes=unit["duration_minutes"], + difficulty=unit["difficulty"], + definition=unit["definition"], + ) + except Exception as e: + logger.error(f"Failed to get unit definition: {e}") + + # Demo unit fallback + if unit_id == "demo_unit_v1": + return UnitDefinitionResponse( + unit_id="demo_unit_v1", + template="flight_path", + version="1.0.0", + locale=["de-DE"], + grade_band=["5", "6", "7"], + duration_minutes=5, + difficulty="base", + definition={ + "unit_id": "demo_unit_v1", + "template": "flight_path", + "version": "1.0.0", + "learning_objectives": ["Demo: Grundfunktion testen"], + "stops": [ + {"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}}, + {"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}}, + {"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}}, + ], + "teacher_controls": {"allow_skip": True, "allow_replay": True}, + }, + ) + + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + +# ============================================== +# Session Endpoints +# ============================================== + +@router.post("/sessions", response_model=SessionResponse) +async def create_unit_session( + request_data: CreateSessionRequest, + request: Request, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> SessionResponse: + """ + Create a new unit session. + + - Validates unit exists + - Creates session record + - Returns session token for telemetry + """ + session_id = str(uuid.uuid4()) + expires_at = datetime.utcnow() + timedelta(hours=4) + + # Validate unit exists + db = await get_unit_database() + if db: + try: + unit = await db.get_unit_definition(request_data.unit_id) + if not unit: + raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}") + + # Create session in database + total_stops = len(unit.get("definition", {}).get("stops", [])) + await db.create_session( + session_id=session_id, + unit_id=request_data.unit_id, + student_id=request_data.student_id, + locale=request_data.locale, + difficulty=request_data.difficulty, + total_stops=total_stops, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create session: {e}") + # Continue with in-memory fallback + + # Create session token + session_token = create_session_token(session_id, request_data.student_id) + + # Build definition URL + base_url = str(request.base_url).rstrip("/") + definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}" + + return SessionResponse( + session_id=session_id, + unit_definition_url=definition_url, + session_token=session_token, + telemetry_endpoint="/api/units/telemetry", + expires_at=expires_at, + ) + + +@router.post("/telemetry", response_model=TelemetryResponse) +async def receive_telemetry( + payload: TelemetryPayload, + request: Request, +) -> TelemetryResponse: + """ + Receive batched telemetry events from Unity client. + + - Validates session token + - Stores events in database + - Returns count of accepted events + """ + # Verify session token + session_data = await get_session_from_token(request) + if session_data is None: + # Allow without auth in dev mode + if REQUIRE_AUTH: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + logger.warning("Telemetry received without valid token (dev mode)") + + # Verify session_id matches + if session_data and session_data.get("session_id") != payload.session_id: + raise HTTPException(status_code=403, detail="Session ID mismatch") + + accepted = 0 + db = await get_unit_database() + + for event in payload.events: + try: + # Set timestamp if not provided + timestamp = event.ts or datetime.utcnow().isoformat() + + if db: + await db.store_telemetry_event( + session_id=payload.session_id, + event_type=event.type, + stop_id=event.stop_id, + timestamp=timestamp, + metrics=event.metrics, + ) + + accepted += 1 + logger.debug(f"Telemetry: {event.type} for session {payload.session_id}") + + except Exception as e: + logger.error(f"Failed to store telemetry event: {e}") + + return TelemetryResponse(accepted=accepted) + + +@router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse) +async def complete_session( + session_id: str, + request_data: CompleteSessionRequest, + request: Request, +) -> SessionSummaryResponse: + """ + Complete a unit session. + + - Processes postcheck answers if provided + - Calculates learning gain + - Returns summary and recommendations + """ + # Verify session token + session_data = await get_session_from_token(request) + if REQUIRE_AUTH and session_data is None: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + + db = await get_unit_database() + summary = {} + recommendations = {} + + if db: + try: + # Get session data + session = await db.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Calculate postcheck score if answers provided + postcheck_score = None + if request_data.postcheck_answers: + # Simple scoring: count correct answers + # In production, would validate against question bank + postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder + postcheck_score = min(postcheck_score, 1.0) + + # Complete session in database + await db.complete_session( + session_id=session_id, + postcheck_score=postcheck_score, + ) + + # Get updated session summary + session = await db.get_session(session_id) + + # Calculate learning gain + pre_score = session.get("precheck_score") + post_score = session.get("postcheck_score") + learning_gain = None + if pre_score is not None and post_score is not None: + learning_gain = post_score - pre_score + + summary = { + "session_id": session_id, + "unit_id": session.get("unit_id"), + "duration_seconds": session.get("duration_seconds"), + "completion_rate": session.get("completion_rate"), + "precheck_score": pre_score, + "postcheck_score": post_score, + "pre_to_post_gain": learning_gain, + "stops_completed": session.get("stops_completed"), + "total_stops": session.get("total_stops"), + } + + # Get recommendations + recommendations = await db.get_recommendations( + student_id=session.get("student_id"), + completed_unit_id=session.get("unit_id"), + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to complete session: {e}") + summary = {"session_id": session_id, "error": str(e)} + + else: + # Fallback summary + summary = { + "session_id": session_id, + "duration_seconds": 0, + "completion_rate": 1.0, + "message": "Database not available", + } + + return SessionSummaryResponse( + summary=summary, + next_recommendations=recommendations or { + "h5p_activity_ids": [], + "worksheet_pdf_url": None, + }, + ) + + +@router.get("/sessions/{session_id}") +async def get_session( + session_id: str, + request: Request, +) -> Dict[str, Any]: + """ + Get session details. + + Returns current state of a session including progress. + """ + # Verify session token + session_data = await get_session_from_token(request) + if REQUIRE_AUTH and session_data is None: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + + db = await get_unit_database() + if db: + try: + session = await db.get_session(session_id) + if session: + return session + except Exception as e: + logger.error(f"Failed to get session: {e}") + + raise HTTPException(status_code=404, detail="Session not found") + + +# ============================================== +# Recommendations & Analytics +# ============================================== + +@router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit]) +async def get_recommendations( + student_id: str, + grade: Optional[str] = Query(None, description="Grade level filter"), + locale: str = Query("de-DE", description="Locale filter"), + limit: int = Query(5, ge=1, le=20), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[RecommendedUnit]: + """ + Get recommended units for a student. + + Based on completion status and performance. + """ + db = await get_unit_database() + if db: + try: + recommendations = await db.get_student_recommendations( + student_id=student_id, + grade=grade, + locale=locale, + limit=limit, + ) + return [ + RecommendedUnit( + unit_id=r["unit_id"], + template=r["template"], + difficulty=r["difficulty"], + reason=r["reason"], + ) + for r in recommendations + ] + except Exception as e: + logger.error(f"Failed to get recommendations: {e}") + + # Fallback: recommend demo unit + return [ + RecommendedUnit( + unit_id="demo_unit_v1", + template="flight_path", + difficulty="base", + reason="Neu: Noch nicht gespielt", + ) + ] + + +@router.get("/analytics/student/{student_id}") +async def get_student_analytics( + student_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Get unit analytics for a student. + + Includes completion rates, learning gains, time spent. + """ + db = await get_unit_database() + if db: + try: + analytics = await db.get_student_unit_analytics(student_id) + return analytics + except Exception as e: + logger.error(f"Failed to get analytics: {e}") + + return { + "student_id": student_id, + "units_attempted": 0, + "units_completed": 0, + "avg_completion_rate": 0.0, + "avg_learning_gain": None, + "total_minutes": 0, + } + + +@router.get("/analytics/unit/{unit_id}") +async def get_unit_analytics( + unit_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Get analytics for a specific unit. + + Shows aggregate performance across all students. + """ + db = await get_unit_database() + if db: + try: + analytics = await db.get_unit_performance(unit_id) + return analytics + except Exception as e: + logger.error(f"Failed to get unit analytics: {e}") + + return { + "unit_id": unit_id, + "total_sessions": 0, + "completed_sessions": 0, + "completion_percent": 0.0, + "avg_duration_minutes": 0, + "avg_learning_gain": None, + } + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """Health check for unit API.""" + db = await get_unit_database() + db_status = "connected" if db else "disconnected" + + return { + "status": "healthy", + "service": "breakpilot-units", + "database": db_status, + "auth_required": REQUIRE_AUTH, + }