""" Classroom API - Session Endpoints. Endpoints fuer Session-Management, Timer, Phasen-Kontrolle und History. """ from uuid import uuid4 from typing import Dict, List, Optional from datetime import datetime import logging from fastapi import APIRouter, HTTPException, Query from classroom_engine import ( LessonPhase, LessonSession, LessonStateMachine, PhaseTimer, SuggestionEngine, ) from .models import ( CreateSessionRequest, NotesRequest, ExtendTimeRequest, SessionResponse, TimerStatus, SuggestionItem, SuggestionsResponse, PhaseInfo, SessionHistoryItem, SessionHistoryResponse, ) from .shared import ( init_db_if_needed, get_session_or_404, persist_session, get_sessions, add_session, ws_manager, DB_ENABLED, logger, ) # Database imports try: from classroom_engine.database import SessionLocal from classroom_engine.repository import SessionRepository except ImportError: pass 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, ) async def notify_phase_change(session_id: str, phase: str, extra_data: dict = None): """Benachrichtigt WebSocket-Clients ueber Phasenwechsel.""" data = {"phase": phase} if extra_data: data.update(extra_data) await ws_manager.broadcast_phase_change(session_id, data) async def notify_session_ended(session_id: str): """Benachrichtigt WebSocket-Clients ueber Session-Ende.""" await ws_manager.broadcast_session_ended(session_id) # === 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() 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, ) add_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) 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") 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) # === 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. """ 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, description="Anzahl Vorschlaege") ) -> 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"], ) @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. """ sessions = get_sessions() if session_id not in sessions: raise HTTPException(status_code=404, detail="Session nicht gefunden") del sessions[session_id] if DB_ENABLED: try: db = SessionLocal() repo = SessionRepository(db) repo.delete(session_id) db.close() 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). """ init_db_if_needed() sessions = get_sessions() 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), page=offset // limit + 1, page_size=limit, ) try: 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, page=offset // limit + 1, page_size=limit, ) except Exception as e: logger.error(f"Failed to get session history: {e}") raise HTTPException(status_code=500, detail="Fehler beim Laden der History")