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:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,110 @@
"""
Classroom API Module
A modular classroom management API for lesson sessions, templates,
homework, materials, analytics, and real-time features.
Usage:
from classroom import router as classroom_router
app.include_router(classroom_router, prefix="/api/classroom")
"""
from fastapi import APIRouter
from .routes.sessions import router as sessions_router
from .routes.templates import router as templates_router
from .routes.homework import router as homework_router
from .routes.materials import router as materials_router
from .routes.analytics import router as analytics_router
from .routes.export import router as export_router
from .routes.feedback import router as feedback_router
from .routes.settings import router as settings_router
from .routes.context import router as context_router
from .routes.websocket_routes import router as websocket_router
# Create the main router that combines all sub-routers
router = APIRouter()
# Include all sub-routers
router.include_router(sessions_router)
router.include_router(templates_router)
router.include_router(homework_router)
router.include_router(materials_router)
router.include_router(analytics_router)
router.include_router(export_router)
router.include_router(feedback_router)
router.include_router(settings_router)
router.include_router(context_router)
router.include_router(websocket_router)
# Re-export commonly used items
from .models import (
# Session Models
CreateSessionRequest,
SessionResponse,
TimerStatus,
# Template Models
TemplateCreate,
TemplateResponse,
# Homework Models
CreateHomeworkRequest,
HomeworkResponse,
# Material Models
CreateMaterialRequest,
MaterialResponse,
# Analytics Models
SessionSummaryResponse,
TeacherAnalyticsResponse,
ReflectionCreate,
ReflectionResponse,
# Feedback Models
FeedbackCreate,
FeedbackResponse,
# Settings Models
TeacherSettingsResponse,
# Context Models
TeacherContextResponse,
)
from .services.persistence import (
sessions,
init_db_if_needed,
DB_ENABLED,
)
from .websocket_manager import (
ws_manager,
notify_phase_change,
notify_session_ended,
)
__all__ = [
# Main router
"router",
# Session storage
"sessions",
"init_db_if_needed",
"DB_ENABLED",
# WebSocket
"ws_manager",
"notify_phase_change",
"notify_session_ended",
# Models
"CreateSessionRequest",
"SessionResponse",
"TimerStatus",
"TemplateCreate",
"TemplateResponse",
"CreateHomeworkRequest",
"HomeworkResponse",
"CreateMaterialRequest",
"MaterialResponse",
"SessionSummaryResponse",
"TeacherAnalyticsResponse",
"ReflectionCreate",
"ReflectionResponse",
"FeedbackCreate",
"FeedbackResponse",
"TeacherSettingsResponse",
"TeacherContextResponse",
]

568
backend/classroom/models.py Normal file
View File

@@ -0,0 +1,568 @@
"""
Classroom API - Pydantic Models
Alle Request- und Response-Models fuer die Classroom API.
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
# === Session Models ===
class CreateSessionRequest(BaseModel):
"""Request zum Erstellen einer neuen Session."""
teacher_id: str = Field(..., description="ID des Lehrers")
class_id: str = Field(..., description="ID der Klasse")
subject: str = Field(..., description="Unterrichtsfach")
topic: Optional[str] = Field(None, description="Thema der Stunde")
phase_durations: Optional[Dict[str, int]] = Field(
None,
description="Optionale individuelle Phasendauern in Minuten"
)
class NotesRequest(BaseModel):
"""Request zum Aktualisieren von Notizen."""
notes: str = Field("", description="Stundennotizen")
homework: str = Field("", description="Hausaufgaben")
class ExtendTimeRequest(BaseModel):
"""Request zum Verlaengern der aktuellen Phase (Feature f28)."""
minutes: int = Field(5, ge=1, le=30, description="Zusaetzliche Minuten (1-30)")
class PhaseInfo(BaseModel):
"""Informationen zu einer Phase."""
phase: str
display_name: str
icon: str
duration_minutes: int
is_completed: bool
is_current: bool
is_future: bool
class TimerStatus(BaseModel):
"""Timer-Status einer Phase."""
remaining_seconds: int
remaining_formatted: str
total_seconds: int
total_formatted: str
elapsed_seconds: int
elapsed_formatted: str
percentage_remaining: int
percentage_elapsed: int
percentage: int = Field(description="Alias fuer percentage_remaining (Visual Timer)")
warning: bool
overtime: bool
overtime_seconds: int
overtime_formatted: Optional[str]
is_paused: bool = Field(False, description="Ist der Timer pausiert?")
class SuggestionItem(BaseModel):
"""Ein Aktivitaets-Vorschlag."""
id: str
title: str
description: str
activity_type: str
estimated_minutes: int
icon: str
content_url: Optional[str]
class SessionResponse(BaseModel):
"""Vollstaendige Session-Response."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
current_phase: str
phase_display_name: str
phase_started_at: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
timer: TimerStatus
phases: List[PhaseInfo]
phase_history: List[Dict[str, Any]]
notes: str
homework: str
is_active: bool
is_ended: bool
is_paused: bool = Field(False, description="Ist die Stunde pausiert?")
class SuggestionsResponse(BaseModel):
"""Response fuer Vorschlaege."""
suggestions: List[SuggestionItem]
current_phase: str
phase_display_name: str
total_available: int
class PhasesListResponse(BaseModel):
"""Liste aller verfuegbaren Phasen."""
phases: List[Dict[str, Any]]
class ActiveSessionsResponse(BaseModel):
"""Liste aktiver Sessions."""
sessions: List[Dict[str, Any]]
count: int
# === Session History Models ===
class SessionHistoryItem(BaseModel):
"""Einzelner Eintrag in der Session-History."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
total_duration_minutes: Optional[int]
phases_completed: int
notes: str
homework: str
class SessionHistoryResponse(BaseModel):
"""Response fuer Session-History."""
sessions: List[SessionHistoryItem]
total_count: int
limit: int
offset: int
# === Template Models ===
class TemplateCreate(BaseModel):
"""Request zum Erstellen einer Vorlage."""
name: str = Field(..., min_length=1, max_length=200, description="Name der Vorlage")
description: str = Field("", max_length=1000, description="Beschreibung")
subject: str = Field("", max_length=100, description="Fach")
grade_level: str = Field("", max_length=50, description="Klassenstufe (z.B. '7', '10')")
phase_durations: Optional[Dict[str, int]] = Field(
None,
description="Phasendauern in Minuten"
)
default_topic: str = Field("", max_length=500, description="Vorausgefuelltes Thema")
default_notes: str = Field("", description="Vorausgefuellte Notizen")
is_public: bool = Field(False, description="Vorlage fuer alle sichtbar?")
class TemplateUpdate(BaseModel):
"""Request zum Aktualisieren einer Vorlage."""
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
subject: Optional[str] = Field(None, max_length=100)
grade_level: Optional[str] = Field(None, max_length=50)
phase_durations: Optional[Dict[str, int]] = None
default_topic: Optional[str] = Field(None, max_length=500)
default_notes: Optional[str] = None
is_public: Optional[bool] = None
class TemplateResponse(BaseModel):
"""Response fuer eine einzelne Vorlage."""
template_id: str
teacher_id: str
name: str
description: str
subject: str
grade_level: str
phase_durations: Dict[str, int]
default_topic: str
default_notes: str
is_public: bool
usage_count: int
total_duration_minutes: int
created_at: Optional[str]
updated_at: Optional[str]
is_system_template: bool = False
class TemplateListResponse(BaseModel):
"""Response fuer Template-Liste."""
templates: List[TemplateResponse]
total_count: int
# === Homework Models ===
class CreateHomeworkRequest(BaseModel):
"""Request zum Erstellen einer Hausaufgabe."""
teacher_id: str
class_id: str
subject: str
title: str = Field(..., max_length=300)
description: str = ""
session_id: Optional[str] = None
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
class UpdateHomeworkRequest(BaseModel):
"""Request zum Aktualisieren einer Hausaufgabe."""
title: Optional[str] = Field(None, max_length=300)
description: Optional[str] = None
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
status: Optional[str] = Field(None, description="assigned, in_progress, completed")
class HomeworkResponse(BaseModel):
"""Response fuer eine Hausaufgabe."""
homework_id: str
teacher_id: str
class_id: str
subject: str
title: str
description: str
session_id: Optional[str]
due_date: Optional[str]
status: str
is_overdue: bool
created_at: Optional[str]
updated_at: Optional[str]
class HomeworkListResponse(BaseModel):
"""Response fuer Liste von Hausaufgaben."""
homework: List[HomeworkResponse]
total: int
# === Material Models ===
class CreateMaterialRequest(BaseModel):
"""Request zum Erstellen eines Materials."""
teacher_id: str
title: str = Field(..., max_length=300)
material_type: str = Field("document", description="document, link, video, image, worksheet, presentation, other")
url: Optional[str] = Field(None, max_length=2000)
description: str = ""
phase: Optional[str] = Field(None, description="einstieg, erarbeitung, sicherung, transfer, reflexion")
subject: str = ""
grade_level: str = ""
tags: List[str] = []
is_public: bool = False
session_id: Optional[str] = None
class UpdateMaterialRequest(BaseModel):
"""Request zum Aktualisieren eines Materials."""
title: Optional[str] = Field(None, max_length=300)
material_type: Optional[str] = None
url: Optional[str] = Field(None, max_length=2000)
description: Optional[str] = None
phase: Optional[str] = None
subject: Optional[str] = None
grade_level: Optional[str] = None
tags: Optional[List[str]] = None
is_public: Optional[bool] = None
class MaterialResponse(BaseModel):
"""Response fuer ein Material."""
material_id: str
teacher_id: str
title: str
material_type: str
url: Optional[str]
description: str
phase: Optional[str]
subject: str
grade_level: str
tags: List[str]
is_public: bool
usage_count: int
session_id: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
class MaterialListResponse(BaseModel):
"""Response fuer Liste von Materialien."""
materials: List[MaterialResponse]
total: int
# === Analytics Models ===
class SessionSummaryResponse(BaseModel):
"""Response fuer Session-Summary."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
date: Optional[str]
date_formatted: str
total_duration_seconds: int
total_duration_formatted: str
planned_duration_seconds: int
planned_duration_formatted: str
phases_completed: int
total_phases: int
completion_percentage: int
phase_statistics: List[Dict[str, Any]]
total_overtime_seconds: int
total_overtime_formatted: str
phases_with_overtime: int
total_pause_count: int
total_pause_seconds: int
reflection_notes: str = ""
reflection_rating: Optional[int] = None
key_learnings: List[str] = []
class TeacherAnalyticsResponse(BaseModel):
"""Response fuer Lehrer-Analytics."""
teacher_id: str
period_start: Optional[str]
period_end: Optional[str]
total_sessions: int
completed_sessions: int
total_teaching_minutes: int
total_teaching_hours: float
avg_phase_durations: Dict[str, int]
sessions_with_overtime: int
overtime_percentage: int
avg_overtime_seconds: int
avg_overtime_formatted: str
most_overtime_phase: Optional[str]
avg_pause_count: float
avg_pause_duration_seconds: int
subjects_taught: Dict[str, int]
classes_taught: Dict[str, int]
class ReflectionCreate(BaseModel):
"""Request-Body fuer Reflection-Erstellung."""
session_id: str
teacher_id: str
notes: str = ""
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: List[str] = []
improvements: List[str] = []
notes_for_next_lesson: str = ""
class ReflectionUpdate(BaseModel):
"""Request-Body fuer Reflection-Update."""
notes: Optional[str] = None
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: Optional[List[str]] = None
improvements: Optional[List[str]] = None
notes_for_next_lesson: Optional[str] = None
class ReflectionResponse(BaseModel):
"""Response fuer eine einzelne Reflection."""
reflection_id: str
session_id: str
teacher_id: str
notes: str
overall_rating: Optional[int]
what_worked: List[str]
improvements: List[str]
notes_for_next_lesson: str
created_at: Optional[str]
updated_at: Optional[str]
# === Feedback Models ===
class FeedbackCreate(BaseModel):
"""Request zum Erstellen von Feedback."""
title: str = Field(..., min_length=3, max_length=500, description="Kurzer Titel")
description: str = Field(..., min_length=10, description="Beschreibung")
feedback_type: str = Field("improvement", description="bug, feature_request, improvement, praise, question")
priority: str = Field("medium", description="critical, high, medium, low")
teacher_name: str = Field("", description="Name des Lehrers")
teacher_email: str = Field("", description="E-Mail fuer Rueckfragen")
context_url: str = Field("", description="URL wo Feedback gegeben wurde")
context_phase: str = Field("", description="Aktuelle Phase")
context_session_id: Optional[str] = Field(None, description="Session-ID falls aktiv")
related_feature: Optional[str] = Field(None, description="Verwandtes Feature")
class FeedbackResponse(BaseModel):
"""Response fuer Feedback."""
id: str
teacher_id: str
teacher_name: str
title: str
description: str
feedback_type: str
priority: str
status: str
created_at: str
response: Optional[str] = None
class FeedbackListResponse(BaseModel):
"""Liste von Feedbacks."""
feedbacks: List[Dict[str, Any]]
total: int
class FeedbackStatsResponse(BaseModel):
"""Feedback-Statistiken."""
total: int
by_status: Dict[str, int]
by_type: Dict[str, int]
by_priority: Dict[str, int]
# === Settings Models ===
class TeacherSettingsResponse(BaseModel):
"""Response fuer Lehrer-Einstellungen."""
teacher_id: str
default_phase_durations: Dict[str, int]
audio_enabled: bool = True
high_contrast: bool = False
show_statistics: bool = True
class UpdatePhaseDurationsRequest(BaseModel):
"""Request zum Aktualisieren der Phasen-Dauern."""
durations: Dict[str, int] = Field(
...,
description="Phasen-Dauern in Minuten, z.B. {'einstieg': 10, 'erarbeitung': 25}",
examples=[{"einstieg": 10, "erarbeitung": 25, "sicherung": 10, "transfer": 8, "reflexion": 5}]
)
class UpdatePreferencesRequest(BaseModel):
"""Request zum Aktualisieren der UI-Praeferenzen."""
audio_enabled: Optional[bool] = None
high_contrast: Optional[bool] = None
show_statistics: Optional[bool] = None
# === Context Models ===
class SchoolInfo(BaseModel):
"""Schul-Informationen."""
federal_state: str
federal_state_name: str = ""
school_type: str
school_type_name: str = ""
class SchoolYearInfo(BaseModel):
"""Schuljahr-Informationen."""
id: str
start: Optional[str] = None
current_week: int = 1
class MacroPhaseInfo(BaseModel):
"""Makro-Phase Informationen."""
id: str
label: str
confidence: float = 1.0
class CoreCounts(BaseModel):
"""Kern-Zaehler fuer den Kontext."""
classes: int = 0
exams_scheduled: int = 0
corrections_pending: int = 0
class ContextFlags(BaseModel):
"""Status-Flags des Kontexts."""
onboarding_completed: bool = False
has_classes: bool = False
has_schedule: bool = False
is_exam_period: bool = False
is_before_holidays: bool = False
class TeacherContextResponse(BaseModel):
"""Response fuer GET /v1/context."""
schema_version: str = "1.0"
teacher_id: str
school: SchoolInfo
school_year: SchoolYearInfo
macro_phase: MacroPhaseInfo
core_counts: CoreCounts
flags: ContextFlags
class UpdateContextRequest(BaseModel):
"""Request zum Aktualisieren des Kontexts."""
federal_state: Optional[str] = None
school_type: Optional[str] = None
schoolyear: Optional[str] = None
schoolyear_start: Optional[str] = None
macro_phase: Optional[str] = None
current_week: Optional[int] = None
# === Event Models ===
class CreateEventRequest(BaseModel):
"""Request zum Erstellen eines Events."""
title: str
event_type: str = "other"
start_date: str
end_date: Optional[str] = None
class_id: Optional[str] = None
subject: Optional[str] = None
description: str = ""
needs_preparation: bool = True
reminder_days_before: int = 7
class EventResponse(BaseModel):
"""Response fuer ein Event."""
id: str
teacher_id: str
event_type: str
title: str
description: str
start_date: str
end_date: Optional[str]
class_id: Optional[str]
subject: Optional[str]
status: str
needs_preparation: bool
preparation_done: bool
reminder_days_before: int
# === Routine Models ===
class CreateRoutineRequest(BaseModel):
"""Request zum Erstellen einer Routine."""
title: str
routine_type: str = "other"
recurrence_pattern: str = "weekly"
day_of_week: Optional[int] = None
day_of_month: Optional[int] = None
time_of_day: Optional[str] = None
duration_minutes: int = 60
description: str = ""
class RoutineResponse(BaseModel):
"""Response fuer eine Routine."""
id: str
teacher_id: str
routine_type: str
title: str
description: str
recurrence_pattern: str
day_of_week: Optional[int]
day_of_month: Optional[int]
time_of_day: Optional[str]
duration_minutes: int
is_active: bool

View File

@@ -0,0 +1,29 @@
"""
Classroom Routes Package
Exports all route modules for the Classroom API.
"""
from .sessions import router as sessions_router
from .templates import router as templates_router
from .homework import router as homework_router
from .materials import router as materials_router
from .analytics import router as analytics_router
from .export import router as export_router
from .feedback import router as feedback_router
from .settings import router as settings_router
from .context import router as context_router
from .websocket_routes import router as websocket_router
__all__ = [
"sessions_router",
"templates_router",
"homework_router",
"materials_router",
"analytics_router",
"export_router",
"feedback_router",
"settings_router",
"context_router",
"websocket_router",
]

View File

@@ -0,0 +1,369 @@
"""
Classroom API - Analytics Routes
Analytics and reflection endpoints (Phase 5).
"""
import uuid
from typing import Dict, Any
from datetime import datetime, timedelta
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import LessonReflection
from ..models import (
SessionSummaryResponse,
TeacherAnalyticsResponse,
ReflectionCreate,
ReflectionUpdate,
ReflectionResponse,
)
from ..services.persistence import (
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Analytics"])
# === Analytics Endpoints ===
@router.get("/analytics/session/{session_id}")
async def get_session_summary(session_id: str) -> SessionSummaryResponse:
"""
Gibt die Analytics-Zusammenfassung einer Session zurueck.
Berechnet Phasen-Dauer Statistiken, Overtime und Pausen-Analyse.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import AnalyticsRepository
repo = AnalyticsRepository(db)
summary = repo.get_session_summary(session_id)
if not summary:
raise HTTPException(
status_code=404,
detail=f"Session {session_id} not found"
)
return SessionSummaryResponse(**summary.to_dict())
@router.get("/analytics/teacher/{teacher_id}")
async def get_teacher_analytics(
teacher_id: str,
days: int = Query(30, ge=1, le=365)
) -> TeacherAnalyticsResponse:
"""
Gibt aggregierte Analytics fuer einen Lehrer zurueck.
Berechnet Trends ueber den angegebenen Zeitraum.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
period_end = datetime.utcnow()
period_start = period_end - timedelta(days=days)
with SessionLocal() as db:
from classroom_engine.repository import AnalyticsRepository
repo = AnalyticsRepository(db)
analytics = repo.get_teacher_analytics(
teacher_id, period_start, period_end
)
return TeacherAnalyticsResponse(**analytics.to_dict())
@router.get("/analytics/phase-trends/{teacher_id}/{phase}")
async def get_phase_trends(
teacher_id: str,
phase: str,
limit: int = Query(20, ge=1, le=100)
) -> Dict[str, Any]:
"""
Gibt die Dauer-Trends fuer eine Phase zurueck.
Nuetzlich fuer Charts und Visualisierungen.
"""
if phase not in ["einstieg", "erarbeitung", "sicherung", "transfer", "reflexion"]:
raise HTTPException(
status_code=400,
detail=f"Invalid phase: {phase}"
)
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import AnalyticsRepository
repo = AnalyticsRepository(db)
trends = repo.get_phase_duration_trends(teacher_id, phase, limit)
return {
"teacher_id": teacher_id,
"phase": phase,
"data_points": trends,
"count": len(trends)
}
@router.get("/analytics/overtime/{teacher_id}")
async def get_overtime_analysis(
teacher_id: str,
limit: int = Query(30, ge=1, le=100)
) -> Dict[str, Any]:
"""
Analysiert Overtime-Muster nach Phase.
Zeigt welche Phasen am haeufigsten ueberzogen werden.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import AnalyticsRepository
repo = AnalyticsRepository(db)
analysis = repo.get_overtime_analysis(teacher_id, limit)
return {
"teacher_id": teacher_id,
"sessions_analyzed": limit,
"phases": analysis
}
# === Reflection Endpoints ===
@router.post("/reflections", status_code=201)
async def create_reflection(data: ReflectionCreate) -> ReflectionResponse:
"""
Erstellt eine Post-Lesson Reflection.
Erlaubt Lehrern, nach der Stunde Notizen zu speichern.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import ReflectionRepository
repo = ReflectionRepository(db)
# Pruefen ob schon eine Reflection existiert
existing = repo.get_by_session(data.session_id)
if existing:
raise HTTPException(
status_code=409,
detail=f"Reflection for session {data.session_id} already exists"
)
reflection = LessonReflection(
reflection_id=str(uuid.uuid4()),
session_id=data.session_id,
teacher_id=data.teacher_id,
notes=data.notes,
overall_rating=data.overall_rating,
what_worked=data.what_worked,
improvements=data.improvements,
notes_for_next_lesson=data.notes_for_next_lesson,
created_at=datetime.utcnow(),
)
db_reflection = repo.create(reflection)
result = repo.to_dataclass(db_reflection)
return ReflectionResponse(**result.to_dict())
@router.get("/reflections/session/{session_id}")
async def get_reflection_by_session(session_id: str) -> ReflectionResponse:
"""
Holt die Reflection einer Session.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import ReflectionRepository
repo = ReflectionRepository(db)
db_reflection = repo.get_by_session(session_id)
if not db_reflection:
raise HTTPException(
status_code=404,
detail=f"No reflection for session {session_id}"
)
result = repo.to_dataclass(db_reflection)
return ReflectionResponse(**result.to_dict())
@router.get("/reflections/teacher/{teacher_id}")
async def get_reflections_by_teacher(
teacher_id: str,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0)
) -> Dict[str, Any]:
"""
Holt alle Reflections eines Lehrers.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import ReflectionRepository
repo = ReflectionRepository(db)
db_reflections = repo.get_by_teacher(teacher_id, limit, offset)
reflections = [
repo.to_dataclass(r).to_dict()
for r in db_reflections
]
return {
"teacher_id": teacher_id,
"reflections": reflections,
"count": len(reflections),
"offset": offset,
"limit": limit
}
@router.put("/reflections/{reflection_id}")
async def update_reflection(
reflection_id: str,
data: ReflectionUpdate,
teacher_id: str = Query(...)
) -> ReflectionResponse:
"""
Aktualisiert eine Reflection.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import ReflectionRepository
repo = ReflectionRepository(db)
db_reflection = repo.get_by_id(reflection_id)
if not db_reflection:
raise HTTPException(
status_code=404,
detail=f"Reflection {reflection_id} not found"
)
if db_reflection.teacher_id != teacher_id:
raise HTTPException(
status_code=403,
detail="Not authorized to update this reflection"
)
# Vorhandene Werte beibehalten wenn nicht im Update
reflection = repo.to_dataclass(db_reflection)
if data.notes is not None:
reflection.notes = data.notes
if data.overall_rating is not None:
reflection.overall_rating = data.overall_rating
if data.what_worked is not None:
reflection.what_worked = data.what_worked
if data.improvements is not None:
reflection.improvements = data.improvements
if data.notes_for_next_lesson is not None:
reflection.notes_for_next_lesson = data.notes_for_next_lesson
reflection.updated_at = datetime.utcnow()
db_updated = repo.update(reflection)
result = repo.to_dataclass(db_updated)
return ReflectionResponse(**result.to_dict())
@router.delete("/reflections/{reflection_id}")
async def delete_reflection(
reflection_id: str,
teacher_id: str = Query(...)
) -> Dict[str, Any]:
"""
Loescht eine Reflection.
"""
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Database not available"
)
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import ReflectionRepository
repo = ReflectionRepository(db)
db_reflection = repo.get_by_id(reflection_id)
if not db_reflection:
raise HTTPException(
status_code=404,
detail=f"Reflection {reflection_id} not found"
)
if db_reflection.teacher_id != teacher_id:
raise HTTPException(
status_code=403,
detail="Not authorized to delete this reflection"
)
success = repo.delete(reflection_id)
return {
"success": success,
"deleted_id": reflection_id
}

View File

@@ -0,0 +1,726 @@
"""
Classroom API - Context Routes
School year context, events, routines, and suggestions endpoints (Phase 8).
"""
from typing import Dict, Any, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query, Depends
from classroom_engine import (
FEDERAL_STATES,
SCHOOL_TYPES,
MacroPhaseEnum,
)
from ..models import (
TeacherContextResponse,
SchoolInfo,
SchoolYearInfo,
MacroPhaseInfo,
CoreCounts,
ContextFlags,
UpdateContextRequest,
CreateEventRequest,
EventResponse,
CreateRoutineRequest,
RoutineResponse,
)
from ..services.persistence import (
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Context"])
def get_db():
"""Database session dependency."""
if DB_ENABLED and SessionLocal:
db = SessionLocal()
try:
yield db
finally:
db.close()
else:
yield None
def _get_macro_phase_label(phase) -> str:
"""Gibt den Anzeigenamen einer Makro-Phase zurueck."""
labels = {
"onboarding": "Einrichtung",
"schuljahresstart": "Schuljahresstart",
"unterrichtsaufbau": "Unterrichtsaufbau",
"leistungsphase_1": "Leistungsphase 1",
"halbjahresabschluss": "Halbjahresabschluss",
"leistungsphase_2": "Leistungsphase 2",
"jahresabschluss": "Jahresabschluss",
}
phase_value = phase.value if hasattr(phase, 'value') else str(phase)
return labels.get(phase_value, phase_value)
# === Context Endpoints ===
@router.get("/v1/context", response_model=TeacherContextResponse)
async def get_teacher_context(
teacher_id: str = Query(..., description="Teacher ID"),
db=Depends(get_db)
):
"""
Liefert den aktuellen Makro-Kontext eines Lehrers.
Der Kontext beinhaltet:
- Schul-Informationen (Bundesland, Schulart)
- Schuljahr-Daten (aktuelles Jahr, Woche)
- Makro-Phase (ONBOARDING bis JAHRESABSCHLUSS)
- Zaehler (Klassen, geplante Klausuren, etc.)
- Status-Flags (Onboarding abgeschlossen, etc.)
"""
if DB_ENABLED and db:
try:
from classroom_engine.repository import TeacherContextRepository, SchoolyearEventRepository
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
# Zaehler berechnen
event_repo = SchoolyearEventRepository(db)
upcoming_exams = event_repo.get_upcoming(teacher_id, days=30)
exams_count = len([e for e in upcoming_exams if e.event_type.value == "exam"])
return TeacherContextResponse(
schema_version="1.0",
teacher_id=teacher_id,
school=SchoolInfo(
federal_state=context.federal_state or "BY",
federal_state_name=FEDERAL_STATES.get(context.federal_state, ""),
school_type=context.school_type or "gymnasium",
school_type_name=SCHOOL_TYPES.get(context.school_type, ""),
),
school_year=SchoolYearInfo(
id=context.schoolyear or "2024-2025",
start=context.schoolyear_start.isoformat() if context.schoolyear_start else None,
current_week=context.current_week or 1,
),
macro_phase=MacroPhaseInfo(
id=context.macro_phase.value,
label=_get_macro_phase_label(context.macro_phase),
confidence=1.0,
),
core_counts=CoreCounts(
classes=1 if context.has_classes else 0,
exams_scheduled=exams_count,
corrections_pending=0,
),
flags=ContextFlags(
onboarding_completed=context.onboarding_completed,
has_classes=context.has_classes,
has_schedule=context.has_schedule,
is_exam_period=context.is_exam_period,
is_before_holidays=context.is_before_holidays,
),
)
except Exception as e:
logger.error(f"Failed to get teacher context: {e}")
raise HTTPException(status_code=500, detail=f"Fehler beim Laden des Kontexts: {e}")
# Fallback ohne DB
return TeacherContextResponse(
schema_version="1.0",
teacher_id=teacher_id,
school=SchoolInfo(
federal_state="BY",
federal_state_name="Bayern",
school_type="gymnasium",
school_type_name="Gymnasium",
),
school_year=SchoolYearInfo(
id="2024-2025",
start=None,
current_week=1,
),
macro_phase=MacroPhaseInfo(
id="onboarding",
label="Einrichtung",
confidence=1.0,
),
core_counts=CoreCounts(),
flags=ContextFlags(),
)
@router.put("/v1/context", response_model=TeacherContextResponse)
async def update_teacher_context(
teacher_id: str,
request: UpdateContextRequest,
db=Depends(get_db)
):
"""
Aktualisiert den Kontext eines Lehrers.
"""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
# Validierung
if request.federal_state and request.federal_state not in FEDERAL_STATES:
raise HTTPException(status_code=400, detail=f"Ungueltiges Bundesland: {request.federal_state}")
if request.school_type and request.school_type not in SCHOOL_TYPES:
raise HTTPException(status_code=400, detail=f"Ungueltige Schulart: {request.school_type}")
# Parse datetime if provided
schoolyear_start = None
if request.schoolyear_start:
schoolyear_start = datetime.fromisoformat(request.schoolyear_start.replace('Z', '+00:00'))
repo.update_context(
teacher_id=teacher_id,
federal_state=request.federal_state,
school_type=request.school_type,
schoolyear=request.schoolyear,
schoolyear_start=schoolyear_start,
macro_phase=request.macro_phase,
current_week=request.current_week,
)
return await get_teacher_context(teacher_id, db)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update teacher context: {e}")
raise HTTPException(status_code=500, detail=f"Fehler beim Aktualisieren: {e}")
@router.post("/v1/context/complete-onboarding")
async def complete_onboarding(
teacher_id: str = Query(...),
db=Depends(get_db)
):
"""Markiert das Onboarding als abgeschlossen."""
if not DB_ENABLED or not db:
return {"success": True, "macro_phase": "schuljahresstart", "note": "DB not available"}
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
context = repo.complete_onboarding(teacher_id)
return {
"success": True,
"macro_phase": context.macro_phase.value,
"teacher_id": teacher_id,
}
except Exception as e:
logger.error(f"Failed to complete onboarding: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.post("/v1/context/reset-onboarding")
async def reset_onboarding(
teacher_id: str = Query(...),
db=Depends(get_db)
):
"""Setzt das Onboarding zurueck (fuer Tests)."""
if not DB_ENABLED or not db:
return {"success": True, "macro_phase": "onboarding", "note": "DB not available"}
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
context.onboarding_completed = False
context.macro_phase = MacroPhaseEnum.ONBOARDING
db.commit()
db.refresh(context)
return {
"success": True,
"macro_phase": "onboarding",
"teacher_id": teacher_id,
}
except Exception as e:
logger.error(f"Failed to reset onboarding: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
# === Events Endpoints ===
@router.get("/v1/events")
async def get_events(
teacher_id: str = Query(...),
status: Optional[str] = None,
event_type: Optional[str] = None,
limit: int = 50,
db=Depends(get_db)
):
"""Holt Events eines Lehrers."""
if not DB_ENABLED or not db:
return {"events": [], "count": 0}
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
events = repo.get_by_teacher(teacher_id, status=status, event_type=event_type, limit=limit)
return {
"events": [repo.to_dict(e) for e in events],
"count": len(events),
}
except Exception as e:
logger.error(f"Failed to get events: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.get("/v1/events/upcoming")
async def get_upcoming_events(
teacher_id: str = Query(...),
days: int = 30,
limit: int = 10,
db=Depends(get_db)
):
"""Holt anstehende Events der naechsten X Tage."""
if not DB_ENABLED or not db:
return {"events": [], "count": 0}
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
events = repo.get_upcoming(teacher_id, days=days, limit=limit)
return {
"events": [repo.to_dict(e) for e in events],
"count": len(events),
}
except Exception as e:
logger.error(f"Failed to get upcoming events: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.post("/v1/events", response_model=EventResponse)
async def create_event(
teacher_id: str,
request: CreateEventRequest,
db=Depends(get_db)
):
"""Erstellt ein neues Schuljahr-Event."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
end_date = None
if request.end_date:
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00'))
event = repo.create(
teacher_id=teacher_id,
title=request.title,
event_type=request.event_type,
start_date=start_date,
end_date=end_date,
class_id=request.class_id,
subject=request.subject,
description=request.description,
needs_preparation=request.needs_preparation,
reminder_days_before=request.reminder_days_before,
)
return EventResponse(
id=event.id,
teacher_id=event.teacher_id,
event_type=event.event_type.value,
title=event.title,
description=event.description,
start_date=event.start_date.isoformat(),
end_date=event.end_date.isoformat() if event.end_date else None,
class_id=event.class_id,
subject=event.subject,
status=event.status.value,
needs_preparation=event.needs_preparation,
preparation_done=event.preparation_done,
reminder_days_before=event.reminder_days_before,
)
except Exception as e:
logger.error(f"Failed to create event: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.delete("/v1/events/{event_id}")
async def delete_event(event_id: str, db=Depends(get_db)):
"""Loescht ein Event."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import SchoolyearEventRepository
repo = SchoolyearEventRepository(db)
if repo.delete(event_id):
return {"success": True, "deleted_id": event_id}
raise HTTPException(status_code=404, detail="Event nicht gefunden")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete event: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
# === Routines Endpoints ===
@router.get("/v1/routines")
async def get_routines(
teacher_id: str = Query(...),
is_active: bool = True,
routine_type: Optional[str] = None,
db=Depends(get_db)
):
"""Holt Routinen eines Lehrers."""
if not DB_ENABLED or not db:
return {"routines": [], "count": 0}
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
routines = repo.get_by_teacher(teacher_id, is_active=is_active, routine_type=routine_type)
return {
"routines": [repo.to_dict(r) for r in routines],
"count": len(routines),
}
except Exception as e:
logger.error(f"Failed to get routines: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.get("/v1/routines/today")
async def get_today_routines(teacher_id: str = Query(...), db=Depends(get_db)):
"""Holt Routinen die heute stattfinden."""
if not DB_ENABLED or not db:
return {"routines": [], "count": 0}
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
routines = repo.get_today(teacher_id)
return {
"routines": [repo.to_dict(r) for r in routines],
"count": len(routines),
}
except Exception as e:
logger.error(f"Failed to get today's routines: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.post("/v1/routines", response_model=RoutineResponse)
async def create_routine(
teacher_id: str,
request: CreateRoutineRequest,
db=Depends(get_db)
):
"""Erstellt eine neue wiederkehrende Routine."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
routine = repo.create(
teacher_id=teacher_id,
title=request.title,
routine_type=request.routine_type,
recurrence_pattern=request.recurrence_pattern,
day_of_week=request.day_of_week,
day_of_month=request.day_of_month,
time_of_day=request.time_of_day,
duration_minutes=request.duration_minutes,
description=request.description,
)
return RoutineResponse(
id=routine.id,
teacher_id=routine.teacher_id,
routine_type=routine.routine_type.value,
title=routine.title,
description=routine.description,
recurrence_pattern=routine.recurrence_pattern.value,
day_of_week=routine.day_of_week,
day_of_month=routine.day_of_month,
time_of_day=routine.time_of_day.isoformat() if routine.time_of_day else None,
duration_minutes=routine.duration_minutes,
is_active=routine.is_active,
)
except Exception as e:
logger.error(f"Failed to create routine: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.delete("/v1/routines/{routine_id}")
async def delete_routine(routine_id: str, db=Depends(get_db)):
"""Loescht eine Routine."""
if not DB_ENABLED or not db:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import RecurringRoutineRepository
repo = RecurringRoutineRepository(db)
if repo.delete(routine_id):
return {"success": True, "deleted_id": routine_id}
raise HTTPException(status_code=404, detail="Routine nicht gefunden")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete routine: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
# === Static Data Endpoints ===
@router.get("/v1/federal-states")
async def get_federal_states():
"""Gibt alle Bundeslaender zurueck."""
return {
"federal_states": [{"id": k, "name": v} for k, v in FEDERAL_STATES.items()]
}
@router.get("/v1/school-types")
async def get_school_types():
"""Gibt alle Schularten zurueck."""
return {
"school_types": [{"id": k, "name": v} for k, v in SCHOOL_TYPES.items()]
}
@router.get("/v1/macro-phases")
async def get_macro_phases():
"""Gibt alle Makro-Phasen mit Beschreibungen zurueck."""
phases = [
{"id": "onboarding", "label": "Einrichtung", "description": "Ersteinrichtung (Klassen, Stundenplan)", "order": 1},
{"id": "schuljahresstart", "label": "Schuljahresstart", "description": "Erste 2-3 Wochen des Schuljahres", "order": 2},
{"id": "unterrichtsaufbau", "label": "Unterrichtsaufbau", "description": "Routinen etablieren, erste Bewertungen", "order": 3},
{"id": "leistungsphase_1", "label": "Leistungsphase 1", "description": "Erste Klassenarbeiten und Klausuren", "order": 4},
{"id": "halbjahresabschluss", "label": "Halbjahresabschluss", "description": "Notenschluss, Zeugnisse, Konferenzen", "order": 5},
{"id": "leistungsphase_2", "label": "Leistungsphase 2", "description": "Zweites Halbjahr, Pruefungsvorbereitung", "order": 6},
{"id": "jahresabschluss", "label": "Jahresabschluss", "description": "Finale Noten, Versetzung, Schuljahresende", "order": 7},
]
return {"macro_phases": phases}
@router.get("/v1/event-types")
async def get_event_types():
"""Gibt alle Event-Typen zurueck."""
types = [
{"id": "exam", "label": "Klassenarbeit/Klausur"},
{"id": "parent_evening", "label": "Elternabend"},
{"id": "trip", "label": "Klassenfahrt/Ausflug"},
{"id": "project", "label": "Projektwoche"},
{"id": "internship", "label": "Praktikum"},
{"id": "presentation", "label": "Referate/Praesentationen"},
{"id": "sports_day", "label": "Sporttag"},
{"id": "school_festival", "label": "Schulfest"},
{"id": "parent_consultation", "label": "Elternsprechtag"},
{"id": "grade_deadline", "label": "Notenschluss"},
{"id": "report_cards", "label": "Zeugnisausgabe"},
{"id": "holiday_start", "label": "Ferienbeginn"},
{"id": "holiday_end", "label": "Ferienende"},
{"id": "other", "label": "Sonstiges"},
]
return {"event_types": types}
@router.get("/v1/routine-types")
async def get_routine_types():
"""Gibt alle Routine-Typen zurueck."""
types = [
{"id": "teacher_conference", "label": "Lehrerkonferenz"},
{"id": "subject_conference", "label": "Fachkonferenz"},
{"id": "office_hours", "label": "Sprechstunde"},
{"id": "team_meeting", "label": "Teamsitzung"},
{"id": "supervision", "label": "Pausenaufsicht"},
{"id": "correction_time", "label": "Korrekturzeit"},
{"id": "prep_time", "label": "Vorbereitungszeit"},
{"id": "other", "label": "Sonstiges"},
]
return {"routine_types": types}
# === Suggestions & Sidebar ===
@router.get("/v1/suggestions")
async def get_suggestions(
teacher_id: str = Query(...),
limit: int = Query(5, ge=1, le=20),
db=Depends(get_db)
):
"""Generiert kontextbasierte Vorschlaege fuer einen Lehrer."""
if DB_ENABLED and db:
try:
from classroom_engine.suggestions import SuggestionGenerator
generator = SuggestionGenerator(db)
result = generator.generate(teacher_id, limit=limit)
return result
except Exception as e:
logger.error(f"Failed to generate suggestions: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
return {
"active_contexts": [],
"suggestions": [],
"signals_summary": {
"macro_phase": "onboarding",
"current_week": 1,
"has_classes": False,
"exams_soon": 0,
"routines_today": 0,
},
"total_suggestions": 0,
}
@router.get("/v1/sidebar")
async def get_sidebar(
teacher_id: str = Query(...),
mode: str = Query("companion"),
db=Depends(get_db)
):
"""Generiert das dynamische Sidebar-Model."""
if mode == "companion":
now_relevant = []
if DB_ENABLED and db:
try:
from classroom_engine.suggestions import SuggestionGenerator
generator = SuggestionGenerator(db)
result = generator.generate(teacher_id, limit=5)
now_relevant = [
{
"id": s["id"],
"label": s["title"],
"state": "recommended" if s["priority"] > 70 else "default",
"badge": s.get("badge"),
"icon": s.get("icon", "lightbulb"),
"action_url": s.get("action_url"),
}
for s in result.get("suggestions", [])
]
except Exception as e:
logger.warning(f"Failed to get suggestions for sidebar: {e}")
return {
"mode": "companion",
"sections": [
{"id": "SEARCH", "type": "search_bar", "placeholder": "Suchen..."},
{
"id": "NOW_RELEVANT",
"type": "list",
"title": "Jetzt relevant",
"items": now_relevant if now_relevant else [
{"id": "no_suggestions", "label": "Keine Vorschlaege", "state": "default", "icon": "check_circle"}
],
},
{
"id": "ALL_MODULES",
"type": "folder",
"label": "Alle Module",
"icon": "folder",
"collapsed": True,
"items": [
{"id": "lesson", "label": "Stundenmodus", "icon": "timer"},
{"id": "classes", "label": "Klassen", "icon": "groups"},
{"id": "exams", "label": "Klausuren", "icon": "quiz"},
{"id": "grades", "label": "Noten", "icon": "calculate"},
{"id": "calendar", "label": "Kalender", "icon": "calendar_month"},
{"id": "materials", "label": "Materialien", "icon": "folder_open"},
],
},
{
"id": "QUICK_ACTIONS",
"type": "actions",
"title": "Kurzaktionen",
"items": [
{"id": "scan", "label": "Scan hochladen", "icon": "upload_file"},
{"id": "note", "label": "Notiz erstellen", "icon": "note_add"},
],
},
],
}
else:
return {
"mode": "classic",
"sections": [
{
"id": "NAVIGATION",
"type": "tree",
"items": [
{"id": "dashboard", "label": "Dashboard", "icon": "dashboard", "url": "/dashboard"},
{"id": "lesson", "label": "Stundenmodus", "icon": "timer", "url": "/lesson"},
{"id": "classes", "label": "Klassen", "icon": "groups", "url": "/classes"},
{"id": "exams", "label": "Klausuren", "icon": "quiz", "url": "/exams"},
{"id": "grades", "label": "Noten", "icon": "calculate", "url": "/grades"},
{"id": "calendar", "label": "Kalender", "icon": "calendar_month", "url": "/calendar"},
{"id": "materials", "label": "Materialien", "icon": "folder_open", "url": "/materials"},
{"id": "settings", "label": "Einstellungen", "icon": "settings", "url": "/settings"},
],
},
],
}
@router.get("/v1/path")
async def get_schoolyear_path(teacher_id: str = Query(...), db=Depends(get_db)):
"""Generiert den Schuljahres-Pfad mit Meilensteinen."""
current_phase = "onboarding"
if DB_ENABLED and db:
try:
from classroom_engine.repository import TeacherContextRepository
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
current_phase = context.macro_phase.value
except Exception as e:
logger.warning(f"Failed to get context for path: {e}")
phase_order = [
"onboarding", "schuljahresstart", "unterrichtsaufbau",
"leistungsphase_1", "halbjahresabschluss", "leistungsphase_2", "jahresabschluss",
]
current_index = phase_order.index(current_phase) if current_phase in phase_order else 0
milestones = [
{"id": "MS_START", "label": "Start", "phase": "onboarding", "icon": "flag"},
{"id": "MS_SETUP", "label": "Einrichtung", "phase": "schuljahresstart", "icon": "tune"},
{"id": "MS_ROUTINE", "label": "Routinen", "phase": "unterrichtsaufbau", "icon": "repeat"},
{"id": "MS_EXAM_1", "label": "Klausuren", "phase": "leistungsphase_1", "icon": "quiz"},
{"id": "MS_HALFYEAR", "label": "Halbjahr", "phase": "halbjahresabschluss", "icon": "event"},
{"id": "MS_EXAM_2", "label": "Pruefungen", "phase": "leistungsphase_2", "icon": "school"},
{"id": "MS_END", "label": "Abschluss", "phase": "jahresabschluss", "icon": "celebration"},
]
for milestone in milestones:
phase = milestone["phase"]
phase_index = phase_order.index(phase) if phase in phase_order else 999
if phase_index < current_index:
milestone["status"] = "done"
elif phase_index == current_index:
milestone["status"] = "current"
else:
milestone["status"] = "upcoming"
current_milestone_id = next(
(m["id"] for m in milestones if m["status"] == "current"),
milestones[0]["id"]
)
progress = int((current_index / (len(phase_order) - 1)) * 100) if len(phase_order) > 1 else 0
return {
"milestones": milestones,
"current_milestone_id": current_milestone_id,
"progress_percent": progress,
"current_phase": current_phase,
}

View File

@@ -0,0 +1,358 @@
"""
Classroom API - Export Routes
PDF/HTML export endpoints (Phase 5).
"""
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse
from classroom_engine import LessonPhase
from ..services.persistence import (
sessions,
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Export"])
@router.get("/export/session/{session_id}", response_class=HTMLResponse)
async def export_session_html(session_id: str) -> HTMLResponse:
"""
Exportiert eine Session-Zusammenfassung als druckbares HTML.
Kann im Browser ueber Strg+P als PDF gespeichert werden.
"""
# Session-Daten aus Memory oder DB holen
session = sessions.get(session_id)
if not session and DB_ENABLED:
init_db_if_needed()
with SessionLocal() as db:
from classroom_engine.repository import AnalyticsRepository
repo = AnalyticsRepository(db)
summary = repo.get_session_summary(session_id)
if not summary:
raise HTTPException(
status_code=404,
detail=f"Session {session_id} not found"
)
# HTML generieren aus Summary
return HTMLResponse(content=_generate_export_html_from_summary(summary))
if not session:
raise HTTPException(
status_code=404,
detail=f"Session {session_id} not found"
)
# HTML aus In-Memory Session generieren
return HTMLResponse(content=_generate_export_html_from_session(session))
def _generate_export_html_from_summary(summary) -> str:
"""Generiert druckbares HTML aus einer SessionSummary."""
phases_html = ""
for phase in summary.phase_statistics:
diff_class = "on-time"
if phase.difference_seconds < -60:
diff_class = "under-time"
elif phase.difference_seconds > 180:
diff_class = "way-over"
elif phase.difference_seconds > 60:
diff_class = "over-time"
phases_html += f"""
<tr>
<td>{phase.display_name}</td>
<td class="center">{phase.planned_duration_seconds // 60}:{phase.planned_duration_seconds % 60:02d}</td>
<td class="center">{phase.actual_duration_seconds // 60}:{phase.actual_duration_seconds % 60:02d}</td>
<td class="center {diff_class}">{phase.difference_formatted}</td>
</tr>
"""
return _get_export_html_template(
subject=summary.subject,
class_id=summary.class_id,
topic=summary.topic,
date_formatted=summary.date_formatted,
total_duration_formatted=summary.total_duration_formatted,
phases_completed=summary.phases_completed,
total_phases=summary.total_phases,
total_overtime_formatted=summary.total_overtime_formatted,
phases_html=phases_html,
phases_with_overtime=summary.phases_with_overtime,
total_overtime_seconds=summary.total_overtime_seconds,
reflection_notes=summary.reflection_notes,
)
def _generate_export_html_from_session(session) -> str:
"""Generiert druckbares HTML aus einer In-Memory Session."""
# Phasen-Tabelle generieren
phases_html = ""
total_overtime = 0
for entry in session.phase_history:
phase = entry.get("phase", "")
if phase in ["not_started", "ended"]:
continue
planned = session.phase_durations.get(phase, 0) * 60
actual = entry.get("duration_seconds", 0) or 0
diff = actual - planned
if diff > 0:
total_overtime += diff
diff_class = "on-time"
if diff < -60:
diff_class = "under-time"
elif diff > 180:
diff_class = "way-over"
elif diff > 60:
diff_class = "over-time"
phase_names = {
"einstieg": "Einstieg",
"erarbeitung": "Erarbeitung",
"sicherung": "Sicherung",
"transfer": "Transfer",
"reflexion": "Reflexion",
}
phases_html += f"""
<tr>
<td>{phase_names.get(phase, phase)}</td>
<td class="center">{planned // 60}:{planned % 60:02d}</td>
<td class="center">{actual // 60}:{actual % 60:02d}</td>
<td class="center {diff_class}">{'+' if diff >= 0 else ''}{diff // 60}:{abs(diff) % 60:02d}</td>
</tr>
"""
# Zeiten
total_duration = 0
date_str = "--"
if session.lesson_started_at:
date_str = session.lesson_started_at.strftime("%d.%m.%Y %H:%M")
if session.lesson_ended_at:
total_duration = int((session.lesson_ended_at - session.lesson_started_at).total_seconds())
total_mins = total_duration // 60
total_secs = total_duration % 60
overtime_mins = total_overtime // 60
overtime_secs = total_overtime % 60
completed_phases = len([e for e in session.phase_history if e.get("ended_at")])
return _get_export_html_template(
subject=session.subject,
class_id=session.class_id,
topic=session.topic,
date_formatted=date_str,
total_duration_formatted=f"{total_mins:02d}:{total_secs:02d}",
phases_completed=completed_phases,
total_phases=5,
total_overtime_formatted=f"{overtime_mins:02d}:{overtime_secs:02d}",
phases_html=phases_html,
phases_with_overtime=len([e for e in session.phase_history if e.get("duration_seconds", 0) > session.phase_durations.get(e.get("phase", ""), 0) * 60]),
total_overtime_seconds=total_overtime,
reflection_notes="",
)
def _get_export_html_template(
subject: str,
class_id: str,
topic: str,
date_formatted: str,
total_duration_formatted: str,
phases_completed: int,
total_phases: int,
total_overtime_formatted: str,
phases_html: str,
phases_with_overtime: int,
total_overtime_seconds: int,
reflection_notes: str,
) -> str:
"""Returns the full HTML template for export."""
return f"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Stundenprotokoll - {subject}</title>
<style>
@page {{
size: A4;
margin: 2cm;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}}
.header {{
border-bottom: 2px solid #1a1a2e;
padding-bottom: 20px;
margin-bottom: 20px;
}}
.title {{
font-size: 20pt;
font-weight: bold;
color: #1a1a2e;
margin: 0;
}}
.subtitle {{
color: #666;
font-size: 12pt;
margin-top: 5px;
}}
.meta-grid {{
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
margin-bottom: 25px;
}}
.meta-item {{
background: #f5f5f5;
padding: 12px 15px;
border-radius: 8px;
}}
.meta-label {{
font-size: 10pt;
color: #666;
margin-bottom: 4px;
}}
.meta-value {{
font-size: 14pt;
font-weight: 600;
color: #1a1a2e;
}}
table {{
width: 100%;
border-collapse: collapse;
margin-bottom: 25px;
}}
th, td {{
border: 1px solid #ddd;
padding: 10px 12px;
text-align: left;
}}
th {{
background: #1a1a2e;
color: white;
font-weight: 600;
}}
tr:nth-child(even) {{
background: #f9f9f9;
}}
.center {{
text-align: center;
}}
.on-time {{ color: #3b82f6; }}
.under-time {{ color: #10b981; }}
.over-time {{ color: #f59e0b; }}
.way-over {{ color: #ef4444; }}
.summary-box {{
background: #fff8e1;
border-left: 4px solid #f59e0b;
padding: 15px;
margin-bottom: 25px;
}}
.footer {{
margin-top: 40px;
padding-top: 15px;
border-top: 1px solid #ddd;
font-size: 9pt;
color: #999;
text-align: center;
}}
@media print {{
body {{
padding: 0;
}}
.no-print {{
display: none;
}}
}}
</style>
</head>
<body>
<div class="header">
<h1 class="title">Stundenprotokoll</h1>
<p class="subtitle">{subject} - Klasse {class_id}{f" - {topic}" if topic else ""}</p>
</div>
<div class="meta-grid">
<div class="meta-item">
<div class="meta-label">Datum</div>
<div class="meta-value">{date_formatted}</div>
</div>
<div class="meta-item">
<div class="meta-label">Gesamtdauer</div>
<div class="meta-value">{total_duration_formatted}</div>
</div>
<div class="meta-item">
<div class="meta-label">Phasen abgeschlossen</div>
<div class="meta-value">{phases_completed}/{total_phases}</div>
</div>
<div class="meta-item">
<div class="meta-label">Overtime gesamt</div>
<div class="meta-value">{total_overtime_formatted}</div>
</div>
</div>
<h2>Phasen-Analyse</h2>
<table>
<thead>
<tr>
<th>Phase</th>
<th class="center">Geplant</th>
<th class="center">Tatsaechlich</th>
<th class="center">Differenz</th>
</tr>
</thead>
<tbody>
{phases_html}
</tbody>
</table>
{f'''
<div class="summary-box">
<strong>Overtime-Zusammenfassung:</strong><br>
{phases_with_overtime} von {total_phases} Phasen hatten Overtime
(gesamt: {total_overtime_formatted})
</div>
''' if total_overtime_seconds > 0 else ''}
{f'''
<h2>Reflexion</h2>
<p>{reflection_notes}</p>
''' if reflection_notes else ''}
<div class="footer">
<p>Erstellt mit BreakPilot Classroom Engine | {datetime.utcnow().strftime("%d.%m.%Y %H:%M")}</p>
<p class="no-print" style="margin-top: 10px;">
<button onclick="window.print()" style="padding: 10px 20px; cursor: pointer;">
Als PDF speichern (Strg+P)
</button>
</p>
</div>
</body>
</html>
"""

View File

@@ -0,0 +1,280 @@
"""
Classroom API - Feedback Routes
Teacher feedback endpoints (Phase 7).
"""
from uuid import uuid4
from typing import Dict, List, Any, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from ..models import (
FeedbackCreate,
FeedbackResponse,
FeedbackListResponse,
FeedbackStatsResponse,
)
from ..services.persistence import (
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Feedback"])
# In-Memory Fallback wenn DB nicht verfuegbar
_feedback_store: List[Dict[str, Any]] = []
async def get_optional_current_user(request: Request) -> Dict[str, Any]:
"""Gets current user from auth token if available."""
# Simplified - in production this would check JWT token
return {
"user_id": "anonymous",
"name": "",
"email": "",
}
@router.post("/feedback", response_model=FeedbackResponse, status_code=201)
async def create_feedback(
data: FeedbackCreate,
request: Request,
teacher_id: Optional[str] = Query(None, description="Lehrer-ID (optional, wird aus Auth Token gelesen)")
) -> FeedbackResponse:
"""
Erstellt neues Lehrer-Feedback.
Ermoeglicht Lehrern, Bug-Reports, Feature-Requests und Verbesserungsvorschlaege
direkt aus dem Lehrer-Frontend zu senden.
Authentifizierung optional - wenn eingeloggt, wird User-ID automatisch verwendet.
"""
init_db_if_needed()
# Auth: User aus Token holen oder Demo-User verwenden
user = await get_optional_current_user(request)
effective_teacher_id = teacher_id or user.get("user_id", "anonymous")
feedback_id = str(uuid4())
created_at = datetime.utcnow()
if DB_ENABLED:
try:
from classroom_engine.repository import TeacherFeedbackRepository
with SessionLocal() as db:
repo = TeacherFeedbackRepository(db)
db_feedback = repo.create(
teacher_id=effective_teacher_id,
title=data.title,
description=data.description,
feedback_type=data.feedback_type,
priority=data.priority,
teacher_name=data.teacher_name or user.get("name", ""),
teacher_email=data.teacher_email or user.get("email", ""),
context_url=data.context_url,
context_phase=data.context_phase,
context_session_id=data.context_session_id,
related_feature=data.related_feature,
)
return FeedbackResponse(
id=db_feedback.id,
teacher_id=db_feedback.teacher_id,
teacher_name=db_feedback.teacher_name,
title=db_feedback.title,
description=db_feedback.description,
feedback_type=db_feedback.feedback_type.value,
priority=db_feedback.priority.value,
status=db_feedback.status.value,
created_at=db_feedback.created_at.isoformat(),
)
except Exception as e:
logger.error(f"Failed to create feedback in DB: {e}")
# Fallback: In-Memory Storage
feedback = {
"id": feedback_id,
"teacher_id": effective_teacher_id,
"teacher_name": data.teacher_name,
"teacher_email": data.teacher_email,
"title": data.title,
"description": data.description,
"feedback_type": data.feedback_type,
"priority": data.priority,
"status": "new",
"related_feature": data.related_feature,
"context_url": data.context_url,
"context_phase": data.context_phase,
"context_session_id": data.context_session_id,
"response": None,
"created_at": created_at.isoformat(),
"updated_at": created_at.isoformat(),
}
_feedback_store.append(feedback)
return FeedbackResponse(
id=feedback_id,
teacher_id=effective_teacher_id,
teacher_name=data.teacher_name or user.get("name", ""),
title=data.title,
description=data.description,
feedback_type=data.feedback_type,
priority=data.priority,
status="new",
created_at=created_at.isoformat(),
)
@router.get("/feedback", response_model=FeedbackListResponse)
async def list_feedback(
status: Optional[str] = Query(None, description="Filter nach Status"),
feedback_type: Optional[str] = Query(None, description="Filter nach Typ"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0)
) -> FeedbackListResponse:
"""
Listet alle Feedbacks (fuer Developer Dashboard).
"""
init_db_if_needed()
if DB_ENABLED:
try:
from classroom_engine.repository import TeacherFeedbackRepository
with SessionLocal() as db:
repo = TeacherFeedbackRepository(db)
feedbacks = repo.get_all(
status=status,
feedback_type=feedback_type,
limit=limit,
offset=offset
)
return FeedbackListResponse(
feedbacks=[repo.to_dict(fb) for fb in feedbacks],
total=len(feedbacks)
)
except Exception as e:
logger.error(f"Failed to list feedback from DB: {e}")
# Fallback: In-Memory
result = _feedback_store
if status:
result = [fb for fb in result if fb.get("status") == status]
if feedback_type:
result = [fb for fb in result if fb.get("feedback_type") == feedback_type]
return FeedbackListResponse(
feedbacks=result[offset:offset + limit],
total=len(result)
)
@router.get("/feedback/stats", response_model=FeedbackStatsResponse)
async def get_feedback_stats() -> FeedbackStatsResponse:
"""
Gibt Feedback-Statistiken zurueck.
"""
init_db_if_needed()
if DB_ENABLED:
try:
from classroom_engine.repository import TeacherFeedbackRepository
with SessionLocal() as db:
repo = TeacherFeedbackRepository(db)
stats = repo.get_stats()
return FeedbackStatsResponse(**stats)
except Exception as e:
logger.error(f"Failed to get feedback stats: {e}")
# Fallback: In-Memory
stats = {
"total": len(_feedback_store),
"by_status": {},
"by_type": {},
"by_priority": {},
}
for fb in _feedback_store:
stats["by_status"][fb["status"]] = stats["by_status"].get(fb["status"], 0) + 1
stats["by_type"][fb["feedback_type"]] = stats["by_type"].get(fb["feedback_type"], 0) + 1
stats["by_priority"][fb["priority"]] = stats["by_priority"].get(fb["priority"], 0) + 1
return FeedbackStatsResponse(**stats)
@router.get("/feedback/{feedback_id}")
async def get_feedback(feedback_id: str) -> Dict[str, Any]:
"""
Ruft ein einzelnes Feedback ab.
"""
init_db_if_needed()
if DB_ENABLED:
try:
from classroom_engine.repository import TeacherFeedbackRepository
with SessionLocal() as db:
repo = TeacherFeedbackRepository(db)
db_feedback = repo.get_by_id(feedback_id)
if db_feedback:
return repo.to_dict(db_feedback)
except Exception as e:
logger.error(f"Failed to get feedback: {e}")
# Fallback: In-Memory
for fb in _feedback_store:
if fb["id"] == feedback_id:
return fb
raise HTTPException(status_code=404, detail="Feedback nicht gefunden")
@router.put("/feedback/{feedback_id}/status")
async def update_feedback_status(
feedback_id: str,
status: str = Query(..., description="Neuer Status"),
response: Optional[str] = Query(None, description="Antwort"),
responded_by: Optional[str] = Query(None, description="Wer antwortet")
) -> Dict[str, Any]:
"""
Aktualisiert den Status eines Feedbacks (fuer Entwickler).
"""
init_db_if_needed()
valid_statuses = ["new", "acknowledged", "planned", "implemented", "declined"]
if status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Ungueltiger Status. Erlaubt: {valid_statuses}"
)
if DB_ENABLED:
try:
from classroom_engine.repository import TeacherFeedbackRepository
with SessionLocal() as db:
repo = TeacherFeedbackRepository(db)
db_feedback = repo.update_status(
feedback_id=feedback_id,
status=status,
response=response,
responded_by=responded_by
)
if db_feedback:
return repo.to_dict(db_feedback)
except Exception as e:
logger.error(f"Failed to update feedback status: {e}")
# Fallback: In-Memory
for fb in _feedback_store:
if fb["id"] == feedback_id:
fb["status"] = status
if response:
fb["response"] = response
fb["responded_by"] = responded_by
fb["responded_at"] = datetime.utcnow().isoformat()
fb["updated_at"] = datetime.utcnow().isoformat()
return fb
raise HTTPException(status_code=404, detail="Feedback nicht gefunden")

View File

@@ -0,0 +1,284 @@
"""
Classroom API - Homework Routes
Homework tracking endpoints (Feature f20).
"""
from uuid import uuid4
from typing import Dict, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import (
Homework,
HomeworkStatus,
)
from ..models import (
CreateHomeworkRequest,
UpdateHomeworkRequest,
HomeworkResponse,
HomeworkListResponse,
)
from ..services.persistence import (
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Homework"])
# In-Memory Storage fuer Hausaufgaben (Fallback)
_homework: Dict[str, Homework] = {}
def _build_homework_response(hw: Homework) -> HomeworkResponse:
"""Baut eine HomeworkResponse aus einem Homework-Objekt."""
is_overdue = False
if hw.due_date and hw.status != HomeworkStatus.COMPLETED:
is_overdue = hw.due_date < datetime.utcnow()
return HomeworkResponse(
homework_id=hw.homework_id,
teacher_id=hw.teacher_id,
class_id=hw.class_id,
subject=hw.subject,
title=hw.title,
description=hw.description,
session_id=hw.session_id,
due_date=hw.due_date.isoformat() if hw.due_date else None,
status=hw.status.value,
is_overdue=is_overdue,
created_at=hw.created_at.isoformat() if hw.created_at else None,
updated_at=hw.updated_at.isoformat() if hw.updated_at else None,
)
@router.post("/homework", response_model=HomeworkResponse, status_code=201)
async def create_homework(request: CreateHomeworkRequest) -> HomeworkResponse:
"""
Erstellt eine neue Hausaufgabe (Feature f20).
Kann mit einer Session verknuepft werden.
"""
init_db_if_needed()
due_date = None
if request.due_date:
try:
due_date = datetime.fromisoformat(request.due_date.replace('Z', '+00:00'))
except ValueError:
raise HTTPException(status_code=400, detail="Ungueltiges Datumsformat")
homework = Homework(
homework_id=str(uuid4()),
teacher_id=request.teacher_id,
class_id=request.class_id,
subject=request.subject,
title=request.title,
description=request.description,
session_id=request.session_id,
due_date=due_date,
status=HomeworkStatus.ASSIGNED,
created_at=datetime.utcnow(),
)
# Persistieren wenn DB verfuegbar
if DB_ENABLED:
try:
from classroom_engine.repository import HomeworkRepository
db = SessionLocal()
repo = HomeworkRepository(db)
repo.create(homework)
db.close()
except Exception as e:
logger.warning(f"DB persist failed for homework: {e}")
_homework[homework.homework_id] = homework
return _build_homework_response(homework)
@router.get("/homework", response_model=HomeworkListResponse)
async def list_homework(
teacher_id: str = Query(None, description="Filter nach Lehrer"),
class_id: str = Query(None, description="Filter nach Klasse"),
status: Optional[str] = Query(None, description="Filter nach Status: assigned, in_progress, completed"),
limit: int = Query(50, ge=1, le=100)
) -> HomeworkListResponse:
"""
Listet Hausaufgaben mit optionalen Filtern (Feature f20).
"""
init_db_if_needed()
homework_list = []
# Aus DB laden wenn verfuegbar
if DB_ENABLED:
try:
from classroom_engine.repository import HomeworkRepository
db = SessionLocal()
repo = HomeworkRepository(db)
db_homework = repo.get_by_filters(
teacher_id=teacher_id,
class_id=class_id,
status=status,
limit=limit
)
for db_hw in db_homework:
hw = repo.to_dataclass(db_hw)
_homework[hw.homework_id] = hw
homework_list.append(_build_homework_response(hw))
db.close()
return HomeworkListResponse(homework=homework_list, total=len(homework_list))
except Exception as e:
logger.warning(f"DB read failed for homework: {e}")
# Fallback auf Memory
for hw in _homework.values():
if teacher_id and hw.teacher_id != teacher_id:
continue
if class_id and hw.class_id != class_id:
continue
if status:
try:
filter_status = HomeworkStatus(status)
if hw.status != filter_status:
continue
except ValueError:
pass
homework_list.append(_build_homework_response(hw))
return HomeworkListResponse(homework=homework_list[:limit], total=len(homework_list))
@router.get("/homework/{homework_id}", response_model=HomeworkResponse)
async def get_homework(homework_id: str) -> HomeworkResponse:
"""
Ruft eine einzelne Hausaufgabe ab (Feature f20).
"""
init_db_if_needed()
# Aus Memory
if homework_id in _homework:
return _build_homework_response(_homework[homework_id])
# Aus DB laden
if DB_ENABLED:
try:
from classroom_engine.repository import HomeworkRepository
db = SessionLocal()
repo = HomeworkRepository(db)
db_hw = repo.get_by_id(homework_id)
db.close()
if db_hw:
hw = repo.to_dataclass(db_hw)
_homework[hw.homework_id] = hw
return _build_homework_response(hw)
except Exception as e:
logger.warning(f"DB read failed: {e}")
raise HTTPException(status_code=404, detail="Hausaufgabe nicht gefunden")
@router.put("/homework/{homework_id}", response_model=HomeworkResponse)
async def update_homework(
homework_id: str,
request: UpdateHomeworkRequest
) -> HomeworkResponse:
"""
Aktualisiert eine Hausaufgabe (Feature f20).
"""
init_db_if_needed()
# Aus Memory holen
homework = _homework.get(homework_id)
# Aus DB laden wenn nicht in Memory
if not homework and DB_ENABLED:
try:
from classroom_engine.repository import HomeworkRepository
db = SessionLocal()
repo = HomeworkRepository(db)
db_hw = repo.get_by_id(homework_id)
db.close()
if db_hw:
homework = repo.to_dataclass(db_hw)
_homework[homework.homework_id] = homework
except Exception as e:
logger.warning(f"DB read failed: {e}")
if not homework:
raise HTTPException(status_code=404, detail="Hausaufgabe nicht gefunden")
# Aktualisieren
if request.title is not None:
homework.title = request.title
if request.description is not None:
homework.description = request.description
if request.due_date is not None:
try:
homework.due_date = datetime.fromisoformat(request.due_date.replace('Z', '+00:00'))
except ValueError:
raise HTTPException(status_code=400, detail="Ungueltiges Datumsformat")
if request.status is not None:
try:
homework.status = HomeworkStatus(request.status)
except ValueError:
raise HTTPException(status_code=400, detail="Ungueltiger Status")
homework.updated_at = datetime.utcnow()
# In DB aktualisieren
if DB_ENABLED:
try:
from classroom_engine.repository import HomeworkRepository
db = SessionLocal()
repo = HomeworkRepository(db)
repo.update(homework)
db.close()
except Exception as e:
logger.warning(f"DB update failed: {e}")
_homework[homework_id] = homework
return _build_homework_response(homework)
@router.patch("/homework/{homework_id}/status")
async def update_homework_status(
homework_id: str,
status: str = Query(..., description="Neuer Status: assigned, in_progress, completed")
) -> HomeworkResponse:
"""
Aktualisiert nur den Status einer Hausaufgabe (Feature f20).
"""
return await update_homework(homework_id, UpdateHomeworkRequest(status=status))
@router.delete("/homework/{homework_id}")
async def delete_homework(homework_id: str) -> Dict[str, str]:
"""
Loescht eine Hausaufgabe (Feature f20).
"""
init_db_if_needed()
if homework_id in _homework:
del _homework[homework_id]
if DB_ENABLED:
try:
from classroom_engine.repository import HomeworkRepository
db = SessionLocal()
repo = HomeworkRepository(db)
repo.delete(homework_id)
db.close()
except Exception as e:
logger.warning(f"DB delete failed: {e}")
return {"status": "deleted", "homework_id": homework_id}

View File

@@ -0,0 +1,329 @@
"""
Classroom API - Materials Routes
Phase materials management endpoints (Feature f19).
"""
from uuid import uuid4
from typing import Dict, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import (
PhaseMaterial,
MaterialType,
)
from ..models import (
CreateMaterialRequest,
UpdateMaterialRequest,
MaterialResponse,
MaterialListResponse,
)
from ..services.persistence import (
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Materials"])
# In-Memory Storage fuer Materialien (Fallback)
_materials: Dict[str, PhaseMaterial] = {}
def _build_material_response(mat: PhaseMaterial) -> MaterialResponse:
"""Baut eine MaterialResponse aus einem PhaseMaterial-Objekt."""
return MaterialResponse(
material_id=mat.material_id,
teacher_id=mat.teacher_id,
title=mat.title,
material_type=mat.material_type.value,
url=mat.url,
description=mat.description,
phase=mat.phase,
subject=mat.subject,
grade_level=mat.grade_level,
tags=mat.tags,
is_public=mat.is_public,
usage_count=mat.usage_count,
session_id=mat.session_id,
created_at=mat.created_at.isoformat() if mat.created_at else None,
updated_at=mat.updated_at.isoformat() if mat.updated_at else None,
)
@router.post("/materials", response_model=MaterialResponse, status_code=201)
async def create_material(request: CreateMaterialRequest) -> MaterialResponse:
"""
Erstellt ein neues Material (Feature f19).
"""
init_db_if_needed()
try:
mat_type = MaterialType(request.material_type)
except ValueError:
mat_type = MaterialType.DOCUMENT
material = PhaseMaterial(
material_id=str(uuid4()),
teacher_id=request.teacher_id,
title=request.title,
material_type=mat_type,
url=request.url,
description=request.description,
phase=request.phase,
subject=request.subject,
grade_level=request.grade_level,
tags=request.tags,
is_public=request.is_public,
usage_count=0,
session_id=request.session_id,
created_at=datetime.utcnow(),
)
# Persistieren wenn DB verfuegbar
if DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
repo.create(material)
db.close()
except Exception as e:
logger.warning(f"DB persist failed for material: {e}")
_materials[material.material_id] = material
return _build_material_response(material)
@router.get("/materials", response_model=MaterialListResponse)
async def list_materials(
teacher_id: str = Query(..., description="ID des Lehrers"),
phase: Optional[str] = Query(None, description="Filter nach Phase"),
subject: Optional[str] = Query(None, description="Filter nach Fach"),
include_public: bool = Query(True, description="Oeffentliche Materialien einbeziehen"),
limit: int = Query(50, ge=1, le=100)
) -> MaterialListResponse:
"""
Listet Materialien eines Lehrers (Feature f19).
"""
init_db_if_needed()
materials_list = []
# Aus DB laden wenn verfuegbar
if DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
if phase:
db_materials = repo.get_by_phase(phase, teacher_id, include_public)
else:
db_materials = repo.get_by_teacher(teacher_id, phase, subject, limit)
for db_mat in db_materials:
mat = repo.to_dataclass(db_mat)
_materials[mat.material_id] = mat
materials_list.append(_build_material_response(mat))
db.close()
return MaterialListResponse(materials=materials_list, total=len(materials_list))
except Exception as e:
logger.warning(f"DB read failed for materials: {e}")
# Fallback auf Memory
for mat in _materials.values():
if mat.teacher_id != teacher_id and not (include_public and mat.is_public):
continue
if phase and mat.phase != phase:
continue
if subject and mat.subject != subject:
continue
materials_list.append(_build_material_response(mat))
return MaterialListResponse(materials=materials_list[:limit], total=len(materials_list))
@router.get("/materials/by-phase/{phase}", response_model=MaterialListResponse)
async def get_materials_by_phase(
phase: str,
teacher_id: str = Query(..., description="ID des Lehrers"),
subject: Optional[str] = Query(None, description="Filter nach Fach"),
limit: int = Query(50, ge=1, le=100)
) -> MaterialListResponse:
"""
Holt Materialien fuer eine bestimmte Phase (Feature f19).
"""
return await list_materials(teacher_id=teacher_id, phase=phase, subject=subject, limit=limit)
@router.get("/materials/{material_id}", response_model=MaterialResponse)
async def get_material(material_id: str) -> MaterialResponse:
"""
Ruft ein einzelnes Material ab (Feature f19).
"""
init_db_if_needed()
# Aus Memory
if material_id in _materials:
return _build_material_response(_materials[material_id])
# Aus DB laden
if DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
db_mat = repo.get_by_id(material_id)
db.close()
if db_mat:
mat = repo.to_dataclass(db_mat)
_materials[mat.material_id] = mat
return _build_material_response(mat)
except Exception as e:
logger.warning(f"DB read failed: {e}")
raise HTTPException(status_code=404, detail="Material nicht gefunden")
@router.put("/materials/{material_id}", response_model=MaterialResponse)
async def update_material(
material_id: str,
request: UpdateMaterialRequest
) -> MaterialResponse:
"""
Aktualisiert ein Material (Feature f19).
"""
init_db_if_needed()
# Aus Memory holen
material = _materials.get(material_id)
# Aus DB laden wenn nicht in Memory
if not material and DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
db_mat = repo.get_by_id(material_id)
db.close()
if db_mat:
material = repo.to_dataclass(db_mat)
_materials[material.material_id] = material
except Exception as e:
logger.warning(f"DB read failed: {e}")
if not material:
raise HTTPException(status_code=404, detail="Material nicht gefunden")
# Aktualisieren
if request.title is not None:
material.title = request.title
if request.material_type is not None:
try:
material.material_type = MaterialType(request.material_type)
except ValueError:
raise HTTPException(status_code=400, detail="Ungueltiger Material-Typ")
if request.url is not None:
material.url = request.url
if request.description is not None:
material.description = request.description
if request.phase is not None:
material.phase = request.phase
if request.subject is not None:
material.subject = request.subject
if request.grade_level is not None:
material.grade_level = request.grade_level
if request.tags is not None:
material.tags = request.tags
if request.is_public is not None:
material.is_public = request.is_public
material.updated_at = datetime.utcnow()
# In DB aktualisieren
if DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
repo.update(material)
db.close()
except Exception as e:
logger.warning(f"DB update failed: {e}")
_materials[material_id] = material
return _build_material_response(material)
@router.post("/materials/{material_id}/attach/{session_id}")
async def attach_material_to_session(
material_id: str,
session_id: str
) -> MaterialResponse:
"""
Verknuepft ein Material mit einer Session (Feature f19).
"""
init_db_if_needed()
material = _materials.get(material_id)
if not material and DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
db_mat = repo.get_by_id(material_id)
if db_mat:
material = repo.to_dataclass(db_mat)
db.close()
except Exception as e:
logger.warning(f"DB read failed: {e}")
if not material:
raise HTTPException(status_code=404, detail="Material nicht gefunden")
material.session_id = session_id
material.usage_count += 1
material.updated_at = datetime.utcnow()
if DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
repo.attach_to_session(material_id, session_id)
db.close()
except Exception as e:
logger.warning(f"DB update failed: {e}")
_materials[material_id] = material
return _build_material_response(material)
@router.delete("/materials/{material_id}")
async def delete_material(material_id: str) -> Dict[str, str]:
"""
Loescht ein Material (Feature f19).
"""
init_db_if_needed()
if material_id in _materials:
del _materials[material_id]
if DB_ENABLED:
try:
from classroom_engine.repository import MaterialRepository
db = SessionLocal()
repo = MaterialRepository(db)
repo.delete(material_id)
db.close()
except Exception as e:
logger.warning(f"DB delete failed: {e}")
return {"status": "deleted", "material_id": material_id}

View 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(),
}

View File

@@ -0,0 +1,184 @@
"""
Classroom API - Settings Routes
Teacher settings endpoints (Feature f16).
"""
import logging
from fastapi import APIRouter, HTTPException, Depends
from classroom_engine import get_default_durations
from ..models import (
TeacherSettingsResponse,
UpdatePhaseDurationsRequest,
UpdatePreferencesRequest,
)
from ..services.persistence import (
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Settings"])
def get_db():
"""Database session dependency."""
if DB_ENABLED and SessionLocal:
db = SessionLocal()
try:
yield db
finally:
db.close()
else:
yield None
@router.get("/settings/{teacher_id}", response_model=TeacherSettingsResponse)
async def get_teacher_settings(
teacher_id: str,
db=Depends(get_db)
):
"""
Holt die Einstellungen eines Lehrers.
Gibt die personalisierten Phasen-Dauern und UI-Praeferenzen zurueck.
Falls keine Einstellungen existieren, werden Defaults erstellt.
Args:
teacher_id: ID des Lehrers
Returns:
TeacherSettingsResponse mit allen Einstellungen
"""
if DB_ENABLED and db:
try:
from classroom_engine.repository import TeacherSettingsRepository
repo = TeacherSettingsRepository(db)
settings = repo.get_or_create(teacher_id)
return TeacherSettingsResponse(
teacher_id=settings.teacher_id,
default_phase_durations=settings.default_phase_durations or get_default_durations(),
audio_enabled=settings.audio_enabled if settings.audio_enabled is not None else True,
high_contrast=settings.high_contrast if settings.high_contrast is not None else False,
show_statistics=settings.show_statistics if settings.show_statistics is not None else True,
)
except Exception as e:
logger.error(f"Failed to get teacher settings: {e}")
raise HTTPException(status_code=500, detail=f"Fehler beim Laden der Einstellungen: {e}")
# Fallback: Defaults
return TeacherSettingsResponse(
teacher_id=teacher_id,
default_phase_durations=get_default_durations(),
audio_enabled=True,
high_contrast=False,
show_statistics=True,
)
@router.put("/settings/{teacher_id}/durations", response_model=TeacherSettingsResponse)
async def update_phase_durations(
teacher_id: str,
request: UpdatePhaseDurationsRequest,
db=Depends(get_db)
):
"""
Aktualisiert die Standard-Phasendauern eines Lehrers.
Ermoeglicht Lehrern, ihre bevorzugten Phasen-Dauern zu speichern.
Diese werden dann bei neuen Sessions als Default verwendet.
Args:
teacher_id: ID des Lehrers
request: Neue Phasen-Dauern in Minuten
Returns:
Aktualisierte TeacherSettingsResponse
"""
# Validierung: Nur gueltige Phasen erlauben
valid_phases = {"einstieg", "erarbeitung", "sicherung", "transfer", "reflexion"}
for phase in request.durations:
if phase not in valid_phases:
raise HTTPException(
status_code=400,
detail=f"Ungueltige Phase: {phase}. Erlaubt: {valid_phases}"
)
if request.durations[phase] < 1 or request.durations[phase] > 120:
raise HTTPException(
status_code=400,
detail=f"Phasen-Dauer muss zwischen 1 und 120 Minuten liegen"
)
if DB_ENABLED and db:
try:
from classroom_engine.repository import TeacherSettingsRepository
repo = TeacherSettingsRepository(db)
settings = repo.update_phase_durations(teacher_id, request.durations)
return TeacherSettingsResponse(
teacher_id=settings.teacher_id,
default_phase_durations=settings.default_phase_durations,
audio_enabled=settings.audio_enabled if settings.audio_enabled is not None else True,
high_contrast=settings.high_contrast if settings.high_contrast is not None else False,
show_statistics=settings.show_statistics if settings.show_statistics is not None else True,
)
except Exception as e:
logger.error(f"Failed to update phase durations: {e}")
raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {e}")
raise HTTPException(
status_code=503,
detail="Datenbank nicht verfuegbar - Einstellungen koennen nicht gespeichert werden"
)
@router.put("/settings/{teacher_id}/preferences", response_model=TeacherSettingsResponse)
async def update_preferences(
teacher_id: str,
request: UpdatePreferencesRequest,
db=Depends(get_db)
):
"""
Aktualisiert die UI-Praeferenzen eines Lehrers.
Ermoeglicht das Speichern von:
- audio_enabled: Audio-Hinweise aktiviert
- high_contrast: Hoher Kontrast fuer Beamer
- show_statistics: Statistiken nach Stundenende anzeigen
Args:
teacher_id: ID des Lehrers
request: Zu aktualisierende Praeferenzen
Returns:
Aktualisierte TeacherSettingsResponse
"""
if DB_ENABLED and db:
try:
from classroom_engine.repository import TeacherSettingsRepository
repo = TeacherSettingsRepository(db)
settings = repo.update_preferences(
teacher_id=teacher_id,
audio_enabled=request.audio_enabled,
high_contrast=request.high_contrast,
show_statistics=request.show_statistics
)
return TeacherSettingsResponse(
teacher_id=settings.teacher_id,
default_phase_durations=settings.default_phase_durations or get_default_durations(),
audio_enabled=settings.audio_enabled if settings.audio_enabled is not None else True,
high_contrast=settings.high_contrast if settings.high_contrast is not None else False,
show_statistics=settings.show_statistics if settings.show_statistics is not None else True,
)
except Exception as e:
logger.error(f"Failed to update preferences: {e}")
raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {e}")
raise HTTPException(
status_code=503,
detail="Datenbank nicht verfuegbar - Einstellungen koennen nicht gespeichert werden"
)

View File

@@ -0,0 +1,382 @@
"""
Classroom API - Template Routes
Lesson template management endpoints (Feature f37).
"""
from uuid import uuid4
from typing import Dict, Optional, List
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import (
LessonSession,
LessonTemplate,
SYSTEM_TEMPLATES,
get_default_durations,
)
from ..models import (
TemplateCreate,
TemplateUpdate,
TemplateResponse,
TemplateListResponse,
)
from ..services.persistence import (
sessions,
init_db_if_needed,
persist_session,
DB_ENABLED,
SessionLocal,
)
from .sessions import build_session_response, SessionResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Templates"])
def _build_template_response(template: LessonTemplate, is_system: bool = False) -> TemplateResponse:
"""Baut eine Template-Response."""
return TemplateResponse(
template_id=template.template_id,
teacher_id=template.teacher_id,
name=template.name,
description=template.description,
subject=template.subject,
grade_level=template.grade_level,
phase_durations=template.phase_durations,
default_topic=template.default_topic,
default_notes=template.default_notes,
is_public=template.is_public,
usage_count=template.usage_count,
total_duration_minutes=sum(template.phase_durations.values()),
created_at=template.created_at.isoformat() if template.created_at else None,
updated_at=template.updated_at.isoformat() if template.updated_at else None,
is_system_template=is_system,
)
def _get_system_templates() -> List[TemplateResponse]:
"""Gibt die vordefinierten System-Templates zurueck."""
templates = []
for t in SYSTEM_TEMPLATES:
template = LessonTemplate(
template_id=t["template_id"],
teacher_id="system",
name=t["name"],
description=t.get("description", ""),
phase_durations=t["phase_durations"],
is_public=True,
usage_count=0,
)
templates.append(_build_template_response(template, is_system=True))
return templates
@router.get("/templates", response_model=TemplateListResponse)
async def list_templates(
teacher_id: Optional[str] = Query(None, description="Filter nach Lehrer"),
subject: Optional[str] = Query(None, description="Filter nach Fach"),
include_system: bool = Query(True, description="System-Vorlagen einbeziehen")
) -> TemplateListResponse:
"""
Listet verfuegbare Stunden-Vorlagen (Feature f37).
Ohne teacher_id werden nur oeffentliche und System-Vorlagen gezeigt.
Mit teacher_id werden auch private Vorlagen des Lehrers angezeigt.
"""
init_db_if_needed()
templates: List[TemplateResponse] = []
# System-Templates hinzufuegen
if include_system:
system_templates = _get_system_templates()
templates.extend(system_templates)
# DB-Templates laden wenn verfuegbar
if DB_ENABLED:
try:
from classroom_engine.repository import TemplateRepository
db = SessionLocal()
repo = TemplateRepository(db)
if subject:
db_templates = repo.get_by_subject(subject, teacher_id)
elif teacher_id:
db_templates = repo.get_by_teacher(teacher_id, include_public=True)
else:
db_templates = repo.get_public_templates()
for db_t in db_templates:
template = repo.to_dataclass(db_t)
templates.append(_build_template_response(template))
db.close()
except Exception as e:
logger.error(f"Failed to load templates from DB: {e}")
return TemplateListResponse(
templates=templates,
total_count=len(templates)
)
@router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template(template_id: str) -> TemplateResponse:
"""
Ruft eine einzelne Vorlage ab.
"""
init_db_if_needed()
# System-Template pruefen
for t in SYSTEM_TEMPLATES:
if t["template_id"] == template_id:
template = LessonTemplate(
template_id=t["template_id"],
teacher_id="system",
name=t["name"],
description=t.get("description", ""),
phase_durations=t["phase_durations"],
is_public=True,
)
return _build_template_response(template, is_system=True)
# DB-Template pruefen
if DB_ENABLED:
try:
from classroom_engine.repository import TemplateRepository
db = SessionLocal()
repo = TemplateRepository(db)
db_template = repo.get_by_id(template_id)
if db_template:
template = repo.to_dataclass(db_template)
db.close()
return _build_template_response(template)
db.close()
except Exception as e:
logger.error(f"Failed to get template {template_id}: {e}")
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
@router.post("/templates", response_model=TemplateResponse, status_code=201)
async def create_template(
request: TemplateCreate,
teacher_id: str = Query(..., description="ID des erstellenden Lehrers")
) -> TemplateResponse:
"""
Erstellt eine neue Stunden-Vorlage.
"""
init_db_if_needed()
if not DB_ENABLED:
raise HTTPException(
status_code=503,
detail="Datenbank nicht verfuegbar - Vorlagen koennen nicht gespeichert werden"
)
phase_durations = request.phase_durations or get_default_durations()
template = LessonTemplate(
template_id=str(uuid4()),
teacher_id=teacher_id,
name=request.name,
description=request.description,
subject=request.subject,
grade_level=request.grade_level,
phase_durations=phase_durations,
default_topic=request.default_topic,
default_notes=request.default_notes,
is_public=request.is_public,
created_at=datetime.utcnow(),
)
try:
from classroom_engine.repository import TemplateRepository
db = SessionLocal()
repo = TemplateRepository(db)
db_template = repo.create(template)
template = repo.to_dataclass(db_template)
db.close()
return _build_template_response(template)
except Exception as e:
logger.error(f"Failed to create template: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Erstellen der Vorlage")
@router.put("/templates/{template_id}", response_model=TemplateResponse)
async def update_template(
template_id: str,
request: TemplateUpdate,
teacher_id: str = Query(..., description="ID des Lehrers (zur Berechtigung)")
) -> TemplateResponse:
"""
Aktualisiert eine Stunden-Vorlage.
Nur der Ersteller kann die Vorlage bearbeiten.
"""
init_db_if_needed()
# System-Templates koennen nicht bearbeitet werden
for t in SYSTEM_TEMPLATES:
if t["template_id"] == template_id:
raise HTTPException(status_code=403, detail="System-Vorlagen koennen nicht bearbeitet werden")
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import TemplateRepository
db = SessionLocal()
repo = TemplateRepository(db)
db_template = repo.get_by_id(template_id)
if not db_template:
db.close()
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
if db_template.teacher_id != teacher_id:
db.close()
raise HTTPException(status_code=403, detail="Keine Berechtigung")
# Nur uebergebene Felder aktualisieren
template = repo.to_dataclass(db_template)
if request.name is not None:
template.name = request.name
if request.description is not None:
template.description = request.description
if request.subject is not None:
template.subject = request.subject
if request.grade_level is not None:
template.grade_level = request.grade_level
if request.phase_durations is not None:
template.phase_durations = request.phase_durations
if request.default_topic is not None:
template.default_topic = request.default_topic
if request.default_notes is not None:
template.default_notes = request.default_notes
if request.is_public is not None:
template.is_public = request.is_public
db_template = repo.update(template)
template = repo.to_dataclass(db_template)
db.close()
return _build_template_response(template)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update template {template_id}: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Aktualisieren der Vorlage")
@router.delete("/templates/{template_id}")
async def delete_template(
template_id: str,
teacher_id: str = Query(..., description="ID des Lehrers (zur Berechtigung)")
) -> Dict[str, str]:
"""
Loescht eine Stunden-Vorlage.
"""
init_db_if_needed()
# System-Templates koennen nicht geloescht werden
for t in SYSTEM_TEMPLATES:
if t["template_id"] == template_id:
raise HTTPException(status_code=403, detail="System-Vorlagen koennen nicht geloescht werden")
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
from classroom_engine.repository import TemplateRepository
db = SessionLocal()
repo = TemplateRepository(db)
db_template = repo.get_by_id(template_id)
if not db_template:
db.close()
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
if db_template.teacher_id != teacher_id:
db.close()
raise HTTPException(status_code=403, detail="Keine Berechtigung")
repo.delete(template_id)
db.close()
return {"status": "deleted", "template_id": template_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete template {template_id}: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Loeschen der Vorlage")
@router.post("/sessions/from-template", response_model=SessionResponse)
async def create_session_from_template(
template_id: str = Query(..., description="ID der Vorlage"),
teacher_id: str = Query(..., description="ID des Lehrers"),
class_id: str = Query(..., description="ID der Klasse"),
topic: Optional[str] = Query(None, description="Optionales Thema (ueberschreibt Default)")
) -> SessionResponse:
"""
Erstellt eine neue Session basierend auf einer Vorlage.
Erhoeht automatisch den Usage-Counter der Vorlage.
"""
init_db_if_needed()
# Template laden
template_data = None
is_system = False
# System-Template pruefen
for t in SYSTEM_TEMPLATES:
if t["template_id"] == template_id:
template_data = t
is_system = True
break
# DB-Template pruefen
if not template_data and DB_ENABLED:
try:
from classroom_engine.repository import TemplateRepository
db = SessionLocal()
repo = TemplateRepository(db)
db_template = repo.get_by_id(template_id)
if db_template:
template_data = {
"phase_durations": db_template.phase_durations or get_default_durations(),
"subject": db_template.subject or "",
"default_topic": db_template.default_topic or "",
"default_notes": db_template.default_notes or "",
}
# Usage Counter erhoehen
repo.increment_usage(template_id)
db.close()
except Exception as e:
logger.error(f"Failed to load template {template_id}: {e}")
if not template_data:
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
# Session erstellen
session = LessonSession(
session_id=str(uuid4()),
teacher_id=teacher_id,
class_id=class_id,
subject=template_data.get("subject", ""),
topic=topic or template_data.get("default_topic", ""),
phase_durations=template_data["phase_durations"],
notes=template_data.get("default_notes", ""),
)
sessions[session.session_id] = session
persist_session(session)
return build_session_response(session)

View File

@@ -0,0 +1,143 @@
"""
Classroom API - WebSocket Routes
Real-time WebSocket endpoints for timer updates.
"""
import json
from typing import Dict, Any
from datetime import datetime
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ..services.persistence import (
sessions,
init_db_if_needed,
DB_ENABLED,
SessionLocal,
)
from ..websocket_manager import (
ws_manager,
start_timer_broadcast,
build_timer_status,
is_timer_broadcast_running,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["WebSocket"])
@router.websocket("/ws/{session_id}")
async def websocket_timer(websocket: WebSocket, session_id: str):
"""
WebSocket-Endpoint fuer Echtzeit-Timer-Updates.
Features:
- Sub-Sekunden Timer-Updates (jede Sekunde)
- Phasenwechsel-Benachrichtigungen
- Session-Ende-Benachrichtigungen
- Multi-Device Support
Protocol:
- Server sendet JSON-Messages mit "type" und "data"
- Types: "timer_update", "phase_change", "session_ended", "error", "connected"
- Client kann "ping" senden fuer Keepalive
"""
# Session validieren bevor Connect
session = sessions.get(session_id)
if not session:
# Versuche aus DB zu laden
if DB_ENABLED:
try:
init_db_if_needed()
from classroom_engine.repository import SessionRepository
db = SessionLocal()
repo = SessionRepository(db)
db_session = repo.get_by_id(session_id)
if db_session:
session = repo.to_dataclass(db_session)
sessions[session_id] = session
db.close()
except Exception as e:
logger.error(f"WebSocket: Failed to load session {session_id}: {e}")
if not session:
await websocket.close(code=4004, reason="Session not found")
return
if session.is_ended:
await websocket.close(code=4001, reason="Session already ended")
return
# Verbindung akzeptieren und registrieren
await ws_manager.connect(websocket, session_id)
# Timer-Broadcast-Task starten wenn noetig
start_timer_broadcast(sessions)
# Initiale Daten senden
try:
initial_timer = build_timer_status(session)
await websocket.send_json({
"type": "connected",
"data": {
"session_id": session_id,
"client_count": ws_manager.get_client_count(session_id),
"timer": initial_timer
}
})
except Exception as e:
logger.error(f"WebSocket: Failed to send initial data: {e}")
# Message-Loop
try:
while True:
try:
message = await websocket.receive_text()
data = json.loads(message)
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
elif data.get("type") == "get_timer":
# Client kann manuell Timer-Status anfordern
session = sessions.get(session_id)
if session and not session.is_ended:
timer_data = build_timer_status(session)
await websocket.send_json({
"type": "timer_update",
"data": timer_data
})
except json.JSONDecodeError:
await websocket.send_json({
"type": "error",
"data": {"message": "Invalid JSON"}
})
except WebSocketDisconnect:
logger.info(f"WebSocket: Client disconnected from session {session_id}")
except Exception as e:
logger.error(f"WebSocket error: {e}")
finally:
await ws_manager.disconnect(websocket)
@router.get("/ws/status")
async def websocket_status() -> Dict[str, Any]:
"""
Status-Endpoint fuer WebSocket-Verbindungen.
Zeigt aktive Sessions und Verbindungszahlen.
"""
active_sessions = ws_manager.get_active_sessions()
return {
"active_sessions": len(active_sessions),
"sessions": [
{
"session_id": sid,
"client_count": ws_manager.get_client_count(sid)
}
for sid in active_sessions
],
"broadcast_task_running": is_timer_broadcast_running()
}

View File

@@ -0,0 +1,23 @@
"""
Classroom Services Package
"""
from .persistence import (
init_db_if_needed,
load_active_sessions_from_db,
persist_session,
get_session_or_404,
DB_ENABLED,
sessions,
SessionLocal,
)
__all__ = [
"init_db_if_needed",
"load_active_sessions_from_db",
"persist_session",
"get_session_or_404",
"DB_ENABLED",
"sessions",
"SessionLocal",
]

View File

@@ -0,0 +1,131 @@
"""
Classroom API - Database Persistence Service
Handles hybrid storage: In-Memory + PostgreSQL for sessions.
"""
import logging
from typing import Dict, Optional
from fastapi import HTTPException
from classroom_engine import LessonSession, LessonPhase
logger = logging.getLogger(__name__)
# Database imports (Feature f22)
try:
from classroom_engine.database import get_db, init_db, SessionLocal
from classroom_engine.repository import SessionRepository
DB_ENABLED = True
except ImportError:
DB_ENABLED = False
SessionLocal = None
logger.warning("Classroom DB not available, using in-memory storage only")
# === Hybrid Storage: In-Memory + DB (Feature f22) ===
# In-Memory fuer schnellen Zugriff, DB fuer Persistenz
sessions: Dict[str, LessonSession] = {}
_db_initialized = False
def init_db_if_needed():
"""Initialisiert DB und laedt aktive Sessions beim ersten Aufruf."""
global _db_initialized
if _db_initialized or not DB_ENABLED:
return
try:
init_db()
load_active_sessions_from_db()
_db_initialized = True
logger.info("Classroom DB initialized, loaded active sessions")
except Exception as e:
logger.error(f"Failed to initialize Classroom DB: {e}")
def load_active_sessions_from_db():
"""Laedt alle aktiven Sessions aus der DB in den Memory-Cache."""
if not DB_ENABLED:
return
try:
db = SessionLocal()
repo = SessionRepository(db)
# Lade alle nicht-beendeten Sessions
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
active_db_sessions = db.query(LessonSessionDB).filter(
LessonSessionDB.current_phase != LessonPhaseEnum.ENDED
).all()
for db_session in active_db_sessions:
session = repo.to_dataclass(db_session)
sessions[session.session_id] = session
logger.info(f"Loaded session {session.session_id} from DB")
db.close()
except Exception as e:
logger.error(f"Failed to load sessions from DB: {e}")
def persist_session(session: LessonSession):
"""Speichert/aktualisiert Session in der DB."""
if not DB_ENABLED:
return
try:
db = SessionLocal()
repo = SessionRepository(db)
existing = repo.get_by_id(session.session_id)
if existing:
repo.update(session)
else:
repo.create(session)
db.close()
except Exception as e:
logger.error(f"Failed to persist session {session.session_id}: {e}")
def get_session_or_404(session_id: str) -> LessonSession:
"""Holt eine Session oder wirft 404. Prueft auch DB bei Cache-Miss."""
init_db_if_needed()
session = sessions.get(session_id)
if session:
return session
# Feature f22: Bei Cache-Miss aus DB laden
if DB_ENABLED:
try:
db = SessionLocal()
repo = SessionRepository(db)
db_session = repo.get_by_id(session_id)
if db_session:
session = repo.to_dataclass(db_session)
sessions[session.session_id] = session # In Cache laden
db.close()
return session
db.close()
except Exception as e:
logger.error(f"Failed to load session {session_id} from DB: {e}")
raise HTTPException(status_code=404, detail="Session nicht gefunden")
def delete_session_from_db(session_id: str):
"""Loescht Session aus der DB."""
if not DB_ENABLED:
return
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}")

View File

@@ -0,0 +1,204 @@
"""
Classroom API - WebSocket Connection Manager
Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates.
"""
import asyncio
import json
import logging
from typing import Dict, List, Optional
from datetime import datetime
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
"""
Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates.
Features:
- Session-basierte Verbindungen (jede Session hat eigene Clients)
- Automatisches Cleanup bei Disconnect
- Broadcast an alle Clients einer Session
- Multi-Device Support
"""
def __init__(self):
# session_id -> Set[WebSocket]
self._connections: Dict[str, set] = {}
# WebSocket -> session_id (reverse lookup)
self._websocket_sessions: Dict[WebSocket, str] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, session_id: str):
"""Verbindet einen Client mit einer Session."""
await websocket.accept()
async with self._lock:
if session_id not in self._connections:
self._connections[session_id] = set()
self._connections[session_id].add(websocket)
self._websocket_sessions[websocket] = session_id
logger.info(f"WebSocket connected to session {session_id}, total clients: {len(self._connections[session_id])}")
async def disconnect(self, websocket: WebSocket):
"""Trennt einen Client."""
async with self._lock:
session_id = self._websocket_sessions.pop(websocket, None)
if session_id and session_id in self._connections:
self._connections[session_id].discard(websocket)
if not self._connections[session_id]:
del self._connections[session_id]
logger.info(f"WebSocket disconnected from session {session_id}")
async def broadcast_to_session(self, session_id: str, message: dict):
"""Sendet eine Nachricht an alle Clients einer Session."""
async with self._lock:
connections = self._connections.get(session_id, set()).copy()
if not connections:
return
message_json = json.dumps(message)
dead_connections = []
for websocket in connections:
try:
await websocket.send_text(message_json)
except Exception as e:
logger.warning(f"Failed to send to websocket: {e}")
dead_connections.append(websocket)
# Cleanup dead connections
for ws in dead_connections:
await self.disconnect(ws)
async def broadcast_timer_update(self, session_id: str, timer_data: dict):
"""Sendet Timer-Update an alle Clients einer Session."""
await self.broadcast_to_session(session_id, {
"type": "timer_update",
"data": timer_data
})
async def broadcast_phase_change(self, session_id: str, phase_data: dict):
"""Sendet Phasenwechsel-Event an alle Clients."""
await self.broadcast_to_session(session_id, {
"type": "phase_change",
"data": phase_data
})
async def broadcast_session_ended(self, session_id: str):
"""Sendet Session-Ende-Event an alle Clients."""
await self.broadcast_to_session(session_id, {
"type": "session_ended",
"data": {"session_id": session_id}
})
def get_client_count(self, session_id: str) -> int:
"""Gibt die Anzahl der verbundenen Clients fuer eine Session zurueck."""
return len(self._connections.get(session_id, set()))
def get_active_sessions(self) -> List[str]:
"""Gibt alle Sessions mit aktiven WebSocket-Verbindungen zurueck."""
return list(self._connections.keys())
# Global connection manager instance
ws_manager = ConnectionManager()
# Background task handle
_timer_broadcast_task: Optional[asyncio.Task] = None
def build_timer_status(session) -> dict:
"""
Baut Timer-Status als dict fuer WebSocket-Broadcast.
Returns dict mit allen Timer-Feldern die der Client benoetigt.
"""
from classroom_engine import PhaseTimer
timer = PhaseTimer()
status = timer.get_phase_status(session)
# Zusaetzliche Felder fuer WebSocket
status["session_id"] = session.session_id
status["current_phase"] = session.current_phase.value
status["is_paused"] = session.is_paused
status["timestamp"] = datetime.utcnow().isoformat()
return status
async def timer_broadcast_loop(sessions_dict: Dict):
"""
Hintergrund-Task der Timer-Updates alle 1 Sekunde an verbundene Clients sendet.
Features:
- Sub-Sekunden Genauigkeit (jede Sekunde)
- Nur aktive Sessions werden aktualisiert
- Automatisches Cleanup bei Fehlern
"""
logger.info("Timer broadcast loop started")
while True:
try:
await asyncio.sleep(1)
active_ws_sessions = ws_manager.get_active_sessions()
if not active_ws_sessions:
continue
for session_id in active_ws_sessions:
session = sessions_dict.get(session_id)
if not session or session.is_ended:
continue
# Timer-Status berechnen
timer_status = build_timer_status(session)
# An alle Clients senden
await ws_manager.broadcast_timer_update(session_id, timer_status)
except asyncio.CancelledError:
logger.info("Timer broadcast loop cancelled")
break
except Exception as e:
logger.error(f"Error in timer broadcast loop: {e}")
await asyncio.sleep(5) # Kurze Pause bei Fehler
def start_timer_broadcast(sessions_dict: Dict):
"""Startet den Timer-Broadcast-Task wenn noch nicht laufend."""
global _timer_broadcast_task
if _timer_broadcast_task is None or _timer_broadcast_task.done():
_timer_broadcast_task = asyncio.create_task(timer_broadcast_loop(sessions_dict))
logger.info("Timer broadcast task created")
def stop_timer_broadcast():
"""Stoppt den Timer-Broadcast-Task."""
global _timer_broadcast_task
if _timer_broadcast_task and not _timer_broadcast_task.done():
_timer_broadcast_task.cancel()
logger.info("Timer broadcast task cancelled")
def is_timer_broadcast_running() -> bool:
"""Prueft ob der Timer-Broadcast-Task laeuft."""
return _timer_broadcast_task is not None and not _timer_broadcast_task.done()
# Broadcast bei Phasenwechsel und Session-Ende
async def notify_phase_change(session_id: str, new_phase: str, phase_info: dict):
"""Benachrichtigt alle verbundenen Clients ueber Phasenwechsel."""
await ws_manager.broadcast_phase_change(session_id, {
"new_phase": new_phase,
"phase_info": phase_info,
"timestamp": datetime.utcnow().isoformat()
})
async def notify_session_ended(session_id: str):
"""Benachrichtigt alle verbundenen Clients ueber Session-Ende."""
await ws_manager.broadcast_session_ended(session_id)