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