""" Classroom API - Session Routes Session management endpoints: create, get, start, next-phase, end, etc. """ 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, SuggestionEngine, LESSON_PHASES, ) 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(), }