A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
435 lines
14 KiB
Python
435 lines
14 KiB
Python
"""
|
|
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")
|