fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
525
backend/classroom/routes/sessions.py
Normal file
525
backend/classroom/routes/sessions.py
Normal file
@@ -0,0 +1,525 @@
|
||||
"""
|
||||
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(),
|
||||
}
|
||||
Reference in New Issue
Block a user