[split-required] Split final 43 files (500-668 LOC) to complete refactoring

klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 09:41:42 +02:00
parent 451365a312
commit bd4b956e3c
113 changed files with 13790 additions and 14148 deletions

View File

@@ -1,525 +1,17 @@
"""
Classroom API - Session Routes
Classroom API - Session Routes (barrel re-export)
Session management endpoints: create, get, start, next-phase, end, etc.
Combines core session routes and action routes into a single router.
"""
from uuid import uuid4
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Query
from .sessions_core import router as core_router, build_session_response
from .sessions_actions import router as actions_router
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
SuggestionEngine,
LESSON_PHASES,
)
router = APIRouter()
router.include_router(core_router)
router.include_router(actions_router)
from ..models import (
CreateSessionRequest,
NotesRequest,
ExtendTimeRequest,
PhaseInfo,
TimerStatus,
SuggestionItem,
SessionResponse,
SuggestionsResponse,
PhasesListResponse,
ActiveSessionsResponse,
SessionHistoryItem,
SessionHistoryResponse,
)
from ..services.persistence import (
sessions,
init_db_if_needed,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from ..websocket_manager import notify_phase_change, notify_session_ended
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
def build_session_response(session: LessonSession) -> SessionResponse:
"""Baut die vollstaendige Session-Response."""
fsm = LessonStateMachine()
timer = PhaseTimer()
timer_status = timer.get_phase_status(session)
phases_info = fsm.get_phases_info(session)
return SessionResponse(
session_id=session.session_id,
teacher_id=session.teacher_id,
class_id=session.class_id,
subject=session.subject,
topic=session.topic,
current_phase=session.current_phase.value,
phase_display_name=session.get_phase_display_name(),
phase_started_at=session.phase_started_at.isoformat() if session.phase_started_at else None,
lesson_started_at=session.lesson_started_at.isoformat() if session.lesson_started_at else None,
lesson_ended_at=session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
timer=TimerStatus(**timer_status),
phases=[PhaseInfo(**p) for p in phases_info],
phase_history=session.phase_history,
notes=session.notes,
homework=session.homework,
is_active=fsm.is_lesson_active(session),
is_ended=fsm.is_lesson_ended(session),
is_paused=session.is_paused,
)
# === Session CRUD Endpoints ===
@router.post("/sessions", response_model=SessionResponse)
async def create_session(request: CreateSessionRequest) -> SessionResponse:
"""
Erstellt eine neue Unterrichtsstunde (Session).
Die Stunde ist nach Erstellung im Status NOT_STARTED.
Zum Starten muss /sessions/{id}/start aufgerufen werden.
"""
init_db_if_needed()
# Default-Dauern mit uebergebenen Werten mergen
phase_durations = {
"einstieg": 8,
"erarbeitung": 20,
"sicherung": 10,
"transfer": 7,
"reflexion": 5,
}
if request.phase_durations:
phase_durations.update(request.phase_durations)
session = LessonSession(
session_id=str(uuid4()),
teacher_id=request.teacher_id,
class_id=request.class_id,
subject=request.subject,
topic=request.topic,
phase_durations=phase_durations,
)
sessions[session.session_id] = session
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str) -> SessionResponse:
"""
Ruft den aktuellen Status einer Session ab.
Enthaelt alle Informationen inkl. Timer-Status und Phasen-Timeline.
"""
session = get_session_or_404(session_id)
return build_session_response(session)
@router.post("/sessions/{session_id}/start", response_model=SessionResponse)
async def start_lesson(session_id: str) -> SessionResponse:
"""
Startet die Unterrichtsstunde.
Wechselt von NOT_STARTED zur ersten Phase (EINSTIEG).
"""
session = get_session_or_404(session_id)
if session.current_phase != LessonPhase.NOT_STARTED:
raise HTTPException(
status_code=400,
detail=f"Stunde bereits gestartet (aktuelle Phase: {session.current_phase.value})"
)
fsm = LessonStateMachine()
session = fsm.transition(session, LessonPhase.EINSTIEG)
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/next-phase", response_model=SessionResponse)
async def next_phase(session_id: str) -> SessionResponse:
"""
Wechselt zur naechsten Phase.
Wirft 400 wenn keine naechste Phase verfuegbar (z.B. bei ENDED).
"""
session = get_session_or_404(session_id)
fsm = LessonStateMachine()
next_p = fsm.next_phase(session.current_phase)
if not next_p:
raise HTTPException(
status_code=400,
detail=f"Keine naechste Phase verfuegbar (aktuelle Phase: {session.current_phase.value})"
)
session = fsm.transition(session, next_p)
persist_session(session)
# WebSocket-Benachrichtigung
response = build_session_response(session)
await notify_phase_change(session_id, session.current_phase.value, {
"phase_display_name": session.get_phase_display_name(),
"is_ended": session.current_phase == LessonPhase.ENDED
})
return response
@router.post("/sessions/{session_id}/end", response_model=SessionResponse)
async def end_lesson(session_id: str) -> SessionResponse:
"""
Beendet die Unterrichtsstunde sofort.
Kann von jeder aktiven Phase aus aufgerufen werden.
"""
session = get_session_or_404(session_id)
if session.current_phase == LessonPhase.ENDED:
raise HTTPException(status_code=400, detail="Stunde bereits beendet")
if session.current_phase == LessonPhase.NOT_STARTED:
raise HTTPException(status_code=400, detail="Stunde noch nicht gestartet")
# Direkt zur Endphase springen (ueberspringt evtl. Phasen)
fsm = LessonStateMachine()
# Phasen bis zum Ende durchlaufen
while session.current_phase != LessonPhase.ENDED:
next_p = fsm.next_phase(session.current_phase)
if next_p:
session = fsm.transition(session, next_p)
else:
break
persist_session(session)
# WebSocket-Benachrichtigung
await notify_session_ended(session_id)
return build_session_response(session)
# === Quick Actions (Feature f26/f27/f28) ===
@router.post("/sessions/{session_id}/pause", response_model=SessionResponse)
async def toggle_pause(session_id: str) -> SessionResponse:
"""
Pausiert oder setzt die laufende Stunde fort (Feature f27).
Toggle-Funktion: Wenn pausiert -> fortsetzen, wenn laufend -> pausieren.
Die Pause-Zeit wird nicht auf die Phasendauer angerechnet.
"""
session = get_session_or_404(session_id)
# Nur aktive Phasen koennen pausiert werden
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(
status_code=400,
detail="Stunde ist nicht aktiv"
)
if session.is_paused:
# Fortsetzen: Pause-Zeit zur Gesamt-Pause addieren
if session.pause_started_at:
pause_duration = (datetime.utcnow() - session.pause_started_at).total_seconds()
session.total_paused_seconds += int(pause_duration)
session.is_paused = False
session.pause_started_at = None
else:
# Pausieren
session.is_paused = True
session.pause_started_at = datetime.utcnow()
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/extend", response_model=SessionResponse)
async def extend_phase(session_id: str, request: ExtendTimeRequest) -> SessionResponse:
"""
Verlaengert die aktuelle Phase um zusaetzliche Minuten (Feature f28).
Nuetzlich wenn mehr Zeit benoetigt wird, z.B. fuer vertiefte Diskussionen.
"""
session = get_session_or_404(session_id)
# Nur aktive Phasen koennen verlaengert werden
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(
status_code=400,
detail="Stunde ist nicht aktiv"
)
# Aktuelle Phasendauer erhoehen
phase_id = session.current_phase.value
current_duration = session.phase_durations.get(phase_id, 10)
session.phase_durations[phase_id] = current_duration + request.minutes
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}/timer", response_model=TimerStatus)
async def get_timer(session_id: str) -> TimerStatus:
"""
Ruft den Timer-Status der aktuellen Phase ab.
Enthaelt verbleibende Zeit, Warnung und Overtime-Status.
Sollte alle 5 Sekunden gepollt werden.
"""
session = get_session_or_404(session_id)
timer = PhaseTimer()
status = timer.get_phase_status(session)
return TimerStatus(**status)
@router.get("/sessions/{session_id}/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(
session_id: str,
limit: int = Query(3, ge=1, le=10, description="Anzahl Vorschlaege")
) -> SuggestionsResponse:
"""
Ruft phasenspezifische Aktivitaets-Vorschlaege ab.
Die Vorschlaege aendern sich je nach aktueller Phase.
"""
session = get_session_or_404(session_id)
engine = SuggestionEngine()
response = engine.get_suggestions_response(session, limit)
return SuggestionsResponse(
suggestions=[SuggestionItem(**s) for s in response["suggestions"]],
current_phase=response["current_phase"],
phase_display_name=response["phase_display_name"],
total_available=response["total_available"],
)
@router.put("/sessions/{session_id}/notes", response_model=SessionResponse)
async def update_notes(session_id: str, request: NotesRequest) -> SessionResponse:
"""
Aktualisiert Notizen und Hausaufgaben der Stunde.
"""
session = get_session_or_404(session_id)
session.notes = request.notes
session.homework = request.homework
persist_session(session)
return build_session_response(session)
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> Dict[str, str]:
"""
Loescht eine Session.
"""
if session_id not in sessions:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
del sessions[session_id]
# Auch aus DB loeschen
if DB_ENABLED:
try:
from ..services.persistence import delete_session_from_db
delete_session_from_db(session_id)
except Exception as e:
logger.error(f"Failed to delete session {session_id} from DB: {e}")
return {"status": "deleted", "session_id": session_id}
# === Session History (Feature f17) ===
@router.get("/history/{teacher_id}", response_model=SessionHistoryResponse)
async def get_session_history(
teacher_id: str,
limit: int = Query(20, ge=1, le=100, description="Max. Anzahl Eintraege"),
offset: int = Query(0, ge=0, description="Offset fuer Pagination")
) -> SessionHistoryResponse:
"""
Ruft die Session-History eines Lehrers ab (Feature f17).
Zeigt abgeschlossene Unterrichtsstunden mit Statistiken.
Nur verfuegbar wenn DB aktiviert ist.
"""
init_db_if_needed()
if not DB_ENABLED:
# Fallback: In-Memory Sessions filtern
ended_sessions = [
s for s in sessions.values()
if s.teacher_id == teacher_id and s.current_phase == LessonPhase.ENDED
]
ended_sessions.sort(
key=lambda x: x.lesson_ended_at or datetime.min,
reverse=True
)
paginated = ended_sessions[offset:offset + limit]
items = []
for s in paginated:
duration = None
if s.lesson_started_at and s.lesson_ended_at:
duration = int((s.lesson_ended_at - s.lesson_started_at).total_seconds() / 60)
items.append(SessionHistoryItem(
session_id=s.session_id,
teacher_id=s.teacher_id,
class_id=s.class_id,
subject=s.subject,
topic=s.topic,
lesson_started_at=s.lesson_started_at.isoformat() if s.lesson_started_at else None,
lesson_ended_at=s.lesson_ended_at.isoformat() if s.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(s.phase_history),
notes=s.notes,
homework=s.homework,
))
return SessionHistoryResponse(
sessions=items,
total_count=len(ended_sessions),
limit=limit,
offset=offset,
)
# DB-basierte History
try:
from classroom_engine.repository import SessionRepository
db = SessionLocal()
repo = SessionRepository(db)
# Beendete Sessions abrufen
db_sessions = repo.get_history_by_teacher(teacher_id, limit, offset)
# Gesamtanzahl ermitteln
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
total_count = db.query(LessonSessionDB).filter(
LessonSessionDB.teacher_id == teacher_id,
LessonSessionDB.current_phase == LessonPhaseEnum.ENDED
).count()
items = []
for db_session in db_sessions:
duration = None
if db_session.lesson_started_at and db_session.lesson_ended_at:
duration = int((db_session.lesson_ended_at - db_session.lesson_started_at).total_seconds() / 60)
phase_history = db_session.phase_history or []
items.append(SessionHistoryItem(
session_id=db_session.id,
teacher_id=db_session.teacher_id,
class_id=db_session.class_id,
subject=db_session.subject,
topic=db_session.topic,
lesson_started_at=db_session.lesson_started_at.isoformat() if db_session.lesson_started_at else None,
lesson_ended_at=db_session.lesson_ended_at.isoformat() if db_session.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(phase_history),
notes=db_session.notes or "",
homework=db_session.homework or "",
))
db.close()
return SessionHistoryResponse(
sessions=items,
total_count=total_count,
limit=limit,
offset=offset,
)
except Exception as e:
logger.error(f"Failed to get session history: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Laden der History")
# === Utility Endpoints ===
@router.get("/phases", response_model=PhasesListResponse)
async def list_phases() -> PhasesListResponse:
"""
Listet alle verfuegbaren Unterrichtsphasen mit Metadaten.
"""
phases = []
for phase_id, config in LESSON_PHASES.items():
phases.append({
"phase": phase_id,
"display_name": config["display_name"],
"default_duration_minutes": config["default_duration_minutes"],
"activities": config["activities"],
"icon": config["icon"],
"description": config.get("description", ""),
})
return PhasesListResponse(phases=phases)
@router.get("/sessions", response_model=ActiveSessionsResponse)
async def list_active_sessions(
teacher_id: Optional[str] = Query(None, description="Filter nach Lehrer")
) -> ActiveSessionsResponse:
"""
Listet alle (optionally gefilterten) Sessions.
"""
sessions_list = []
for session in sessions.values():
if teacher_id and session.teacher_id != teacher_id:
continue
fsm = LessonStateMachine()
sessions_list.append({
"session_id": session.session_id,
"teacher_id": session.teacher_id,
"class_id": session.class_id,
"subject": session.subject,
"current_phase": session.current_phase.value,
"is_active": fsm.is_lesson_active(session),
"lesson_started_at": session.lesson_started_at.isoformat() if session.lesson_started_at else None,
})
return ActiveSessionsResponse(
sessions=sessions_list,
count=len(sessions_list)
)
@router.get("/health")
async def health_check() -> Dict[str, Any]:
"""
Health-Check fuer den Classroom Service.
"""
from sqlalchemy import text
db_status = "disabled"
if DB_ENABLED:
try:
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy",
"service": "classroom-engine",
"active_sessions": len(sessions),
"db_enabled": DB_ENABLED,
"db_status": db_status,
"timestamp": datetime.utcnow().isoformat(),
}
# Re-export for backward compatibility
__all__ = ["router", "build_session_response"]

View File

@@ -0,0 +1,173 @@
"""
Classroom API - Session Actions Routes
Quick actions (pause, extend, timer), suggestions, utility endpoints.
"""
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text
from classroom_engine import (
LessonPhase,
LessonStateMachine,
PhaseTimer,
SuggestionEngine,
LESSON_PHASES,
)
from ..models import (
ExtendTimeRequest,
TimerStatus,
SuggestionItem,
SuggestionsResponse,
PhasesListResponse,
ActiveSessionsResponse,
)
from ..services.persistence import (
sessions,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from .sessions_core import build_session_response, SessionResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
# === Quick Actions (Feature f26/f27/f28) ===
@router.post("/sessions/{session_id}/pause", response_model=SessionResponse)
async def toggle_pause(session_id: str) -> SessionResponse:
"""Pausiert oder setzt die laufende Stunde fort (Feature f27)."""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
if session.is_paused:
if session.pause_started_at:
pause_duration = (datetime.utcnow() - session.pause_started_at).total_seconds()
session.total_paused_seconds += int(pause_duration)
session.is_paused = False
session.pause_started_at = None
else:
session.is_paused = True
session.pause_started_at = datetime.utcnow()
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/extend", response_model=SessionResponse)
async def extend_phase(session_id: str, request: ExtendTimeRequest) -> SessionResponse:
"""Verlaengert die aktuelle Phase um zusaetzliche Minuten (Feature f28)."""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
phase_id = session.current_phase.value
current_duration = session.phase_durations.get(phase_id, 10)
session.phase_durations[phase_id] = current_duration + request.minutes
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}/timer", response_model=TimerStatus)
async def get_timer(session_id: str) -> TimerStatus:
"""Ruft den Timer-Status der aktuellen Phase ab."""
session = get_session_or_404(session_id)
timer = PhaseTimer()
status = timer.get_phase_status(session)
return TimerStatus(**status)
@router.get("/sessions/{session_id}/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(
session_id: str,
limit: int = Query(3, ge=1, le=10)
) -> SuggestionsResponse:
"""Ruft phasenspezifische Aktivitaets-Vorschlaege ab."""
session = get_session_or_404(session_id)
engine = SuggestionEngine()
response = engine.get_suggestions_response(session, limit)
return SuggestionsResponse(
suggestions=[SuggestionItem(**s) for s in response["suggestions"]],
current_phase=response["current_phase"],
phase_display_name=response["phase_display_name"],
total_available=response["total_available"],
)
# === Utility Endpoints ===
@router.get("/phases", response_model=PhasesListResponse)
async def list_phases() -> PhasesListResponse:
"""Listet alle verfuegbaren Unterrichtsphasen mit Metadaten."""
phases = []
for phase_id, config in LESSON_PHASES.items():
phases.append({
"phase": phase_id,
"display_name": config["display_name"],
"default_duration_minutes": config["default_duration_minutes"],
"activities": config["activities"],
"icon": config["icon"],
"description": config.get("description", ""),
})
return PhasesListResponse(phases=phases)
@router.get("/sessions", response_model=ActiveSessionsResponse)
async def list_active_sessions(
teacher_id: Optional[str] = Query(None)
) -> ActiveSessionsResponse:
"""Listet alle (optionally gefilterten) Sessions."""
sessions_list = []
for session in sessions.values():
if teacher_id and session.teacher_id != teacher_id:
continue
fsm = LessonStateMachine()
sessions_list.append({
"session_id": session.session_id,
"teacher_id": session.teacher_id,
"class_id": session.class_id,
"subject": session.subject,
"current_phase": session.current_phase.value,
"is_active": fsm.is_lesson_active(session),
"lesson_started_at": session.lesson_started_at.isoformat() if session.lesson_started_at else None,
})
return ActiveSessionsResponse(sessions=sessions_list, count=len(sessions_list))
@router.get("/health")
async def health_check() -> Dict[str, Any]:
"""Health-Check fuer den Classroom Service."""
db_status = "disabled"
if DB_ENABLED:
try:
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy",
"service": "classroom-engine",
"active_sessions": len(sessions),
"db_enabled": DB_ENABLED,
"db_status": db_status,
"timestamp": datetime.utcnow().isoformat(),
}

View File

@@ -0,0 +1,283 @@
"""
Classroom API - Session Core Routes
Session CRUD, lifecycle, and history endpoints.
"""
from uuid import uuid4
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
LESSON_PHASES,
)
from ..models import (
CreateSessionRequest,
NotesRequest,
PhaseInfo,
TimerStatus,
SessionResponse,
PhasesListResponse,
ActiveSessionsResponse,
SessionHistoryItem,
SessionHistoryResponse,
)
from ..services.persistence import (
sessions,
init_db_if_needed,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from ..websocket_manager import notify_phase_change, notify_session_ended
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
def build_session_response(session: LessonSession) -> SessionResponse:
"""Baut die vollstaendige Session-Response."""
fsm = LessonStateMachine()
timer = PhaseTimer()
timer_status = timer.get_phase_status(session)
phases_info = fsm.get_phases_info(session)
return SessionResponse(
session_id=session.session_id,
teacher_id=session.teacher_id,
class_id=session.class_id,
subject=session.subject,
topic=session.topic,
current_phase=session.current_phase.value,
phase_display_name=session.get_phase_display_name(),
phase_started_at=session.phase_started_at.isoformat() if session.phase_started_at else None,
lesson_started_at=session.lesson_started_at.isoformat() if session.lesson_started_at else None,
lesson_ended_at=session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
timer=TimerStatus(**timer_status),
phases=[PhaseInfo(**p) for p in phases_info],
phase_history=session.phase_history,
notes=session.notes,
homework=session.homework,
is_active=fsm.is_lesson_active(session),
is_ended=fsm.is_lesson_ended(session),
is_paused=session.is_paused,
)
# === Session CRUD Endpoints ===
@router.post("/sessions", response_model=SessionResponse)
async def create_session(request: CreateSessionRequest) -> SessionResponse:
"""Erstellt eine neue Unterrichtsstunde (Session)."""
init_db_if_needed()
phase_durations = {
"einstieg": 8, "erarbeitung": 20, "sicherung": 10,
"transfer": 7, "reflexion": 5,
}
if request.phase_durations:
phase_durations.update(request.phase_durations)
session = LessonSession(
session_id=str(uuid4()),
teacher_id=request.teacher_id,
class_id=request.class_id,
subject=request.subject,
topic=request.topic,
phase_durations=phase_durations,
)
sessions[session.session_id] = session
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str) -> SessionResponse:
"""Ruft den aktuellen Status einer Session ab."""
session = get_session_or_404(session_id)
return build_session_response(session)
@router.post("/sessions/{session_id}/start", response_model=SessionResponse)
async def start_lesson(session_id: str) -> SessionResponse:
"""Startet die Unterrichtsstunde."""
session = get_session_or_404(session_id)
if session.current_phase != LessonPhase.NOT_STARTED:
raise HTTPException(
status_code=400,
detail=f"Stunde bereits gestartet (aktuelle Phase: {session.current_phase.value})"
)
fsm = LessonStateMachine()
session = fsm.transition(session, LessonPhase.EINSTIEG)
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/next-phase", response_model=SessionResponse)
async def next_phase(session_id: str) -> SessionResponse:
"""Wechselt zur naechsten Phase."""
session = get_session_or_404(session_id)
fsm = LessonStateMachine()
next_p = fsm.next_phase(session.current_phase)
if not next_p:
raise HTTPException(
status_code=400,
detail=f"Keine naechste Phase verfuegbar (aktuelle Phase: {session.current_phase.value})"
)
session = fsm.transition(session, next_p)
persist_session(session)
response = build_session_response(session)
await notify_phase_change(session_id, session.current_phase.value, {
"phase_display_name": session.get_phase_display_name(),
"is_ended": session.current_phase == LessonPhase.ENDED
})
return response
@router.post("/sessions/{session_id}/end", response_model=SessionResponse)
async def end_lesson(session_id: str) -> SessionResponse:
"""Beendet die Unterrichtsstunde sofort."""
session = get_session_or_404(session_id)
if session.current_phase == LessonPhase.ENDED:
raise HTTPException(status_code=400, detail="Stunde bereits beendet")
if session.current_phase == LessonPhase.NOT_STARTED:
raise HTTPException(status_code=400, detail="Stunde noch nicht gestartet")
fsm = LessonStateMachine()
while session.current_phase != LessonPhase.ENDED:
next_p = fsm.next_phase(session.current_phase)
if next_p:
session = fsm.transition(session, next_p)
else:
break
persist_session(session)
await notify_session_ended(session_id)
return build_session_response(session)
@router.put("/sessions/{session_id}/notes", response_model=SessionResponse)
async def update_notes(session_id: str, request: NotesRequest) -> SessionResponse:
"""Aktualisiert Notizen und Hausaufgaben der Stunde."""
session = get_session_or_404(session_id)
session.notes = request.notes
session.homework = request.homework
persist_session(session)
return build_session_response(session)
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> Dict[str, str]:
"""Loescht eine Session."""
if session_id not in sessions:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
del sessions[session_id]
if DB_ENABLED:
try:
from ..services.persistence import delete_session_from_db
delete_session_from_db(session_id)
except Exception as e:
logger.error(f"Failed to delete session {session_id} from DB: {e}")
return {"status": "deleted", "session_id": session_id}
# === Session History (Feature f17) ===
@router.get("/history/{teacher_id}", response_model=SessionHistoryResponse)
async def get_session_history(
teacher_id: str,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0)
) -> SessionHistoryResponse:
"""Ruft die Session-History eines Lehrers ab (Feature f17)."""
init_db_if_needed()
if not DB_ENABLED:
ended_sessions = [
s for s in sessions.values()
if s.teacher_id == teacher_id and s.current_phase == LessonPhase.ENDED
]
ended_sessions.sort(key=lambda x: x.lesson_ended_at or datetime.min, reverse=True)
paginated = ended_sessions[offset:offset + limit]
items = []
for s in paginated:
duration = None
if s.lesson_started_at and s.lesson_ended_at:
duration = int((s.lesson_ended_at - s.lesson_started_at).total_seconds() / 60)
items.append(SessionHistoryItem(
session_id=s.session_id, teacher_id=s.teacher_id,
class_id=s.class_id, subject=s.subject, topic=s.topic,
lesson_started_at=s.lesson_started_at.isoformat() if s.lesson_started_at else None,
lesson_ended_at=s.lesson_ended_at.isoformat() if s.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(s.phase_history),
notes=s.notes, homework=s.homework,
))
return SessionHistoryResponse(
sessions=items, total_count=len(ended_sessions), limit=limit, offset=offset,
)
try:
from classroom_engine.repository import SessionRepository
db = SessionLocal()
repo = SessionRepository(db)
db_sessions = repo.get_history_by_teacher(teacher_id, limit, offset)
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
total_count = db.query(LessonSessionDB).filter(
LessonSessionDB.teacher_id == teacher_id,
LessonSessionDB.current_phase == LessonPhaseEnum.ENDED
).count()
items = []
for db_session in db_sessions:
duration = None
if db_session.lesson_started_at and db_session.lesson_ended_at:
duration = int((db_session.lesson_ended_at - db_session.lesson_started_at).total_seconds() / 60)
phase_history = db_session.phase_history or []
items.append(SessionHistoryItem(
session_id=db_session.id, teacher_id=db_session.teacher_id,
class_id=db_session.class_id, subject=db_session.subject, topic=db_session.topic,
lesson_started_at=db_session.lesson_started_at.isoformat() if db_session.lesson_started_at else None,
lesson_ended_at=db_session.lesson_ended_at.isoformat() if db_session.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(phase_history),
notes=db_session.notes or "", homework=db_session.homework or "",
))
db.close()
return SessionHistoryResponse(
sessions=items, total_count=total_count, limit=limit, offset=offset,
)
except Exception as e:
logger.error(f"Failed to get session history: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Laden der History")