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>
284 lines
9.8 KiB
Python
284 lines
9.8 KiB
Python
"""
|
|
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")
|