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,70 @@
"""
Classroom API - Modularer Router.
Dieser Router sammelt alle Classroom-bezogenen Endpoints aus den Submodulen.
Für Rückwärtskompatibilität kann der alte classroom_api.py Pfad weiterhin
verwendet werden.
Struktur:
- sessions.py: Session CRUD, Timer, Phasen, History
- templates.py: Stunden-Vorlagen
- homework.py: Hausaufgaben-Tracking
- materials.py: Unterrichtsmaterialien
- analytics.py: Analytics & Reflexionen
- feedback.py: Lehrer-Feedback
- settings.py: Lehrer-Einstellungen
- utility.py: Health, Phases, Export
- context.py: Teacher Context (v1 API)
"""
from fastapi import APIRouter
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 .feedback import router as feedback_router
from .settings import router as settings_router
from .utility import router as utility_router
from .context import router as context_router
# Haupt-Router mit Prefix
router = APIRouter(prefix="/api/classroom", tags=["Classroom"])
# Sub-Router einbinden
router.include_router(sessions_router)
router.include_router(templates_router)
router.include_router(homework_router)
router.include_router(materials_router)
router.include_router(analytics_router)
router.include_router(feedback_router)
router.include_router(settings_router)
router.include_router(utility_router)
router.include_router(context_router)
# Re-exports für einfachen Import
from .models import (
CreateSessionRequest,
SessionResponse,
TimerStatus,
SuggestionsResponse,
)
from .shared import (
ws_manager,
get_session_or_404,
start_timer_broadcast,
stop_timer_broadcast,
)
__all__ = [
"router",
"ws_manager",
"get_session_or_404",
"start_timer_broadcast",
"stop_timer_broadcast",
"CreateSessionRequest",
"SessionResponse",
"TimerStatus",
"SuggestionsResponse",
]

View File

@@ -0,0 +1,343 @@
"""
Classroom API - Analytics & Reflections Endpoints.
Endpoints fuer Session-Analytics und Post-Lesson Reflections (Phase 5).
"""
from uuid import uuid4
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from .shared import init_db_if_needed, DB_ENABLED, logger
try:
from classroom_engine.database import SessionLocal
from classroom_engine.repository import AnalyticsRepository, ReflectionRepository
from classroom_engine.analytics import LessonReflection
except ImportError:
pass
router = APIRouter(tags=["Analytics"])
# === Pydantic Models ===
class SessionSummaryResponse(BaseModel):
"""Response fuer Session-Summary."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
date: Optional[str]
date_formatted: str
total_duration_seconds: int
total_duration_formatted: str
planned_duration_seconds: int
planned_duration_formatted: str
phases_completed: int
total_phases: int
completion_percentage: int
phase_statistics: List[Dict[str, Any]]
total_overtime_seconds: int
total_overtime_formatted: str
phases_with_overtime: int
total_pause_count: int
total_pause_seconds: int
reflection_notes: str = ""
reflection_rating: Optional[int] = None
key_learnings: List[str] = []
class TeacherAnalyticsResponse(BaseModel):
"""Response fuer Lehrer-Analytics."""
teacher_id: str
period_start: Optional[str]
period_end: Optional[str]
total_sessions: int
completed_sessions: int
total_teaching_minutes: int
total_teaching_hours: float
avg_phase_durations: Dict[str, int]
sessions_with_overtime: int
overtime_percentage: int
avg_overtime_seconds: int
avg_overtime_formatted: str
most_overtime_phase: Optional[str]
avg_pause_count: float
avg_pause_duration_seconds: int
subjects_taught: Dict[str, int]
classes_taught: Dict[str, int]
class ReflectionCreate(BaseModel):
"""Request-Body fuer Reflection-Erstellung."""
session_id: str
teacher_id: str
notes: str = ""
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: List[str] = []
improvements: List[str] = []
notes_for_next_lesson: str = ""
class ReflectionUpdate(BaseModel):
"""Request-Body fuer Reflection-Update."""
notes: Optional[str] = None
overall_rating: Optional[int] = Field(None, ge=1, le=5)
what_worked: Optional[List[str]] = None
improvements: Optional[List[str]] = None
notes_for_next_lesson: Optional[str] = None
class ReflectionResponse(BaseModel):
"""Response fuer eine einzelne Reflection."""
reflection_id: str
session_id: str
teacher_id: str
notes: str
overall_rating: Optional[int]
what_worked: List[str]
improvements: List[str]
notes_for_next_lesson: str
created_at: Optional[str]
updated_at: Optional[str]
class ReflectionListResponse(BaseModel):
"""Response fuer Reflection-Liste."""
reflections: List[ReflectionResponse]
total: int
# === Analytics Endpoints ===
@router.get("/analytics/session/{session_id}")
async def get_session_summary(session_id: str) -> SessionSummaryResponse:
"""Gibt die Analytics-Zusammenfassung einer Session zurueck."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Database not available")
init_db_if_needed()
with SessionLocal() as db:
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."""
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:
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."""
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:
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."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Database not available")
init_db_if_needed()
with SessionLocal() as db:
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."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Database not available")
init_db_if_needed()
with SessionLocal() as db:
repo = ReflectionRepository(db)
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(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:
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)
) -> ReflectionListResponse:
"""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:
repo = ReflectionRepository(db)
db_reflections = repo.get_by_teacher(teacher_id, limit, offset)
reflections = []
for db_ref in db_reflections:
result = repo.to_dataclass(db_ref)
reflections.append(ReflectionResponse(**result.to_dict()))
total = repo.count_by_teacher(teacher_id)
return ReflectionListResponse(reflections=reflections, total=total)
@router.put("/reflections/{reflection_id}")
async def update_reflection(reflection_id: str, data: ReflectionUpdate) -> 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:
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")
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_reflection = repo.update(reflection)
result = repo.to_dataclass(db_reflection)
return ReflectionResponse(**result.to_dict())
@router.delete("/reflections/{reflection_id}")
async def delete_reflection(reflection_id: str) -> Dict[str, str]:
"""Loescht eine Reflection."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Database not available")
init_db_if_needed()
with SessionLocal() as db:
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")
repo.delete(reflection_id)
return {"status": "deleted", "reflection_id": reflection_id}

View File

@@ -0,0 +1,687 @@
"""
Classroom API - Teacher Context Endpoints (v1 API).
Endpoints fuer Teacher Context, Events, Routines und Antizipations-Engine.
"""
from typing import Dict, List, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as DBSession
from .shared import init_db_if_needed, DB_ENABLED, logger
try:
from classroom_engine.database import get_db, SessionLocal
from classroom_engine.repository import (
TeacherContextRepository, SchoolyearEventRepository, RecurringRoutineRepository
)
from classroom_engine.context_models import (
MacroPhaseEnum, EventTypeEnum, EventStatusEnum,
RoutineTypeEnum, RecurrencePatternEnum,
FEDERAL_STATES, SCHOOL_TYPES
)
from classroom_engine.antizipation import SuggestionGenerator
except ImportError:
FEDERAL_STATES = {}
SCHOOL_TYPES = {}
router = APIRouter(prefix="/v1", tags=["Teacher Context"])
# === Pydantic Models ===
class SchoolInfo(BaseModel):
federal_state: str
federal_state_name: str
school_type: str
school_type_name: str
class SchoolYearInfo(BaseModel):
id: str
start: Optional[str]
current_week: int
class MacroPhaseInfo(BaseModel):
id: str
label: str
confidence: float
class CoreCounts(BaseModel):
classes: int = 0
exams_scheduled: int = 0
corrections_pending: int = 0
class ContextFlags(BaseModel):
onboarding_completed: bool = False
has_classes: bool = False
has_schedule: bool = False
is_exam_period: bool = False
is_before_holidays: bool = False
class TeacherContextResponse(BaseModel):
schema_version: str = "1.0"
teacher_id: str
school: SchoolInfo
school_year: SchoolYearInfo
macro_phase: MacroPhaseInfo
core_counts: CoreCounts
flags: ContextFlags
class UpdateContextRequest(BaseModel):
federal_state: Optional[str] = None
school_type: Optional[str] = None
schoolyear: Optional[str] = None
schoolyear_start: Optional[str] = None
macro_phase: Optional[str] = None
current_week: Optional[int] = None
class CreateEventRequest(BaseModel):
title: str = Field(..., max_length=300)
event_type: str = "other"
start_date: str
end_date: Optional[str] = None
class_id: Optional[str] = None
subject: Optional[str] = None
description: str = ""
needs_preparation: bool = False
reminder_days_before: int = 3
class EventResponse(BaseModel):
id: str
teacher_id: str
event_type: str
title: str
description: str
start_date: str
end_date: Optional[str]
class_id: Optional[str]
subject: Optional[str]
status: str
needs_preparation: bool
preparation_done: bool
reminder_days_before: int
class CreateRoutineRequest(BaseModel):
title: str
routine_type: str = "other"
recurrence_pattern: str = "weekly"
day_of_week: Optional[int] = None
day_of_month: Optional[int] = None
time_of_day: Optional[str] = None
duration_minutes: int = 60
description: str = ""
class RoutineResponse(BaseModel):
id: str
teacher_id: str
routine_type: str
title: str
description: str
recurrence_pattern: str
day_of_week: Optional[int]
day_of_month: Optional[int]
time_of_day: Optional[str]
duration_minutes: int
is_active: bool
# === Helper Functions ===
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)
def get_default_context_response(teacher_id: str) -> TeacherContextResponse:
"""Gibt eine Default-Context-Response zurueck."""
return TeacherContextResponse(
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(),
)
# === Context Endpoints ===
@router.get("/context", response_model=TeacherContextResponse)
async def get_teacher_context(teacher_id: str = Query(...)):
"""Liefert den aktuellen Makro-Kontext eines Lehrers."""
if not DB_ENABLED:
return get_default_context_response(teacher_id)
try:
db = SessionLocal()
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
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"])
result = TeacherContextResponse(
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,
),
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,
),
)
db.close()
return result
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}")
@router.put("/context", response_model=TeacherContextResponse)
async def update_teacher_context(teacher_id: str, request: UpdateContextRequest):
"""Aktualisiert den Kontext eines Lehrers."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
db = SessionLocal()
repo = TeacherContextRepository(db)
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}")
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,
)
db.close()
return await get_teacher_context(teacher_id)
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("/context/complete-onboarding")
async def complete_onboarding(teacher_id: str = Query(...)):
"""Markiert das Onboarding als abgeschlossen."""
if not DB_ENABLED:
return {"success": True, "macro_phase": "schuljahresstart", "note": "DB not available"}
try:
db = SessionLocal()
repo = TeacherContextRepository(db)
context = repo.complete_onboarding(teacher_id)
db.close()
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("/context/reset-onboarding")
async def reset_onboarding(teacher_id: str = Query(...)):
"""Setzt das Onboarding zurueck (fuer Tests)."""
if not DB_ENABLED:
return {"success": True, "macro_phase": "onboarding", "note": "DB not available"}
try:
db = SessionLocal()
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
context.onboarding_completed = False
context.macro_phase = MacroPhaseEnum.ONBOARDING
db.commit()
db.close()
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("/events")
async def get_events(
teacher_id: str = Query(...),
status: Optional[str] = None,
event_type: Optional[str] = None,
limit: int = 50
):
"""Holt Events eines Lehrers."""
if not DB_ENABLED:
return {"events": [], "count": 0}
try:
db = SessionLocal()
repo = SchoolyearEventRepository(db)
events = repo.get_by_teacher(teacher_id, status=status, event_type=event_type, limit=limit)
result = {"events": [repo.to_dict(e) for e in events], "count": len(events)}
db.close()
return result
except Exception as e:
logger.error(f"Failed to get events: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.get("/events/upcoming")
async def get_upcoming_events(teacher_id: str = Query(...), days: int = 30, limit: int = 10):
"""Holt anstehende Events der naechsten X Tage."""
if not DB_ENABLED:
return {"events": [], "count": 0}
try:
db = SessionLocal()
repo = SchoolyearEventRepository(db)
events = repo.get_upcoming(teacher_id, days=days, limit=limit)
result = {"events": [repo.to_dict(e) for e in events], "count": len(events)}
db.close()
return result
except Exception as e:
logger.error(f"Failed to get upcoming events: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.post("/events", response_model=EventResponse)
async def create_event(teacher_id: str, request: CreateEventRequest):
"""Erstellt ein neues Schuljahr-Event."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
db = SessionLocal()
repo = SchoolyearEventRepository(db)
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00')) if request.end_date else None
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,
)
result = 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,
)
db.close()
return result
except Exception as e:
logger.error(f"Failed to create event: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.delete("/events/{event_id}")
async def delete_event(event_id: str):
"""Loescht ein Event."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
db = SessionLocal()
repo = SchoolyearEventRepository(db)
if repo.delete(event_id):
db.close()
return {"success": True, "deleted_id": event_id}
db.close()
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("/routines")
async def get_routines(
teacher_id: str = Query(...),
is_active: bool = True,
routine_type: Optional[str] = None
):
"""Holt Routinen eines Lehrers."""
if not DB_ENABLED:
return {"routines": [], "count": 0}
try:
db = SessionLocal()
repo = RecurringRoutineRepository(db)
routines = repo.get_by_teacher(teacher_id, is_active=is_active, routine_type=routine_type)
result = {"routines": [repo.to_dict(r) for r in routines], "count": len(routines)}
db.close()
return result
except Exception as e:
logger.error(f"Failed to get routines: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.get("/routines/today")
async def get_today_routines(teacher_id: str = Query(...)):
"""Holt Routinen die heute stattfinden."""
if not DB_ENABLED:
return {"routines": [], "count": 0}
try:
db = SessionLocal()
repo = RecurringRoutineRepository(db)
routines = repo.get_today(teacher_id)
result = {"routines": [repo.to_dict(r) for r in routines], "count": len(routines)}
db.close()
return result
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("/routines", response_model=RoutineResponse)
async def create_routine(teacher_id: str, request: CreateRoutineRequest):
"""Erstellt eine neue wiederkehrende Routine."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
db = SessionLocal()
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,
)
result = 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,
)
db.close()
return result
except Exception as e:
logger.error(f"Failed to create routine: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
@router.delete("/routines/{routine_id}")
async def delete_routine(routine_id: str):
"""Loescht eine Routine."""
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
try:
db = SessionLocal()
repo = RecurringRoutineRepository(db)
if repo.delete(routine_id):
db.close()
return {"success": True, "deleted_id": routine_id}
db.close()
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("/federal-states")
async def get_federal_states_list():
"""Gibt alle Bundeslaender zurueck."""
return {"federal_states": [{"id": k, "name": v} for k, v in FEDERAL_STATES.items()]}
@router.get("/school-types")
async def get_school_types_list():
"""Gibt alle Schularten zurueck."""
return {"school_types": [{"id": k, "name": v} for k, v in SCHOOL_TYPES.items()]}
@router.get("/macro-phases")
async def get_macro_phases_list():
"""Gibt alle Makro-Phasen zurueck."""
return {
"macro_phases": [
{"id": "onboarding", "label": "Einrichtung", "order": 1},
{"id": "schuljahresstart", "label": "Schuljahresstart", "order": 2},
{"id": "unterrichtsaufbau", "label": "Unterrichtsaufbau", "order": 3},
{"id": "leistungsphase_1", "label": "Leistungsphase 1", "order": 4},
{"id": "halbjahresabschluss", "label": "Halbjahresabschluss", "order": 5},
{"id": "leistungsphase_2", "label": "Leistungsphase 2", "order": 6},
{"id": "jahresabschluss", "label": "Jahresabschluss", "order": 7},
]
}
@router.get("/event-types")
async def get_event_types_list():
"""Gibt alle Event-Typen zurueck."""
return {
"event_types": [
{"id": "exam", "label": "Klassenarbeit/Klausur"},
{"id": "parent_evening", "label": "Elternabend"},
{"id": "trip", "label": "Klassenfahrt/Ausflug"},
{"id": "project", "label": "Projektwoche"},
{"id": "other", "label": "Sonstiges"},
]
}
@router.get("/routine-types")
async def get_routine_types_list():
"""Gibt alle Routine-Typen zurueck."""
return {
"routine_types": [
{"id": "teacher_conference", "label": "Lehrerkonferenz"},
{"id": "subject_conference", "label": "Fachkonferenz"},
{"id": "office_hours", "label": "Sprechstunde"},
{"id": "correction_time", "label": "Korrekturzeit"},
{"id": "other", "label": "Sonstiges"},
]
}
# === Antizipations-Engine ===
@router.get("/suggestions")
async def get_suggestions(teacher_id: str = Query(...), limit: int = Query(5, ge=1, le=20)):
"""Generiert kontextbasierte Vorschlaege fuer einen Lehrer."""
if not DB_ENABLED:
return {
"active_contexts": [],
"suggestions": [],
"signals_summary": {"macro_phase": "onboarding"},
"total_suggestions": 0,
}
try:
db = SessionLocal()
generator = SuggestionGenerator(db)
result = generator.generate(teacher_id, limit=limit)
db.close()
return result
except Exception as e:
logger.error(f"Failed to generate suggestions: {e}")
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
# === Sidebar ===
@router.get("/sidebar")
async def get_sidebar(teacher_id: str = Query(...), mode: str = Query("companion")):
"""Generiert das dynamische Sidebar-Model."""
if mode == "companion":
return {
"mode": "companion",
"sections": [
{"id": "SEARCH", "type": "search_bar", "placeholder": "Suchen..."},
{"id": "NOW_RELEVANT", "type": "list", "title": "Jetzt relevant", "items": []},
{
"id": "ALL_MODULES",
"type": "folder",
"label": "Alle Module",
"collapsed": True,
"items": [
{"id": "lesson", "label": "Stundenmodus", "icon": "timer"},
{"id": "classes", "label": "Klassen", "icon": "groups"},
{"id": "exams", "label": "Klausuren", "icon": "quiz"},
],
},
],
}
return {
"mode": "classic",
"sections": [
{
"id": "NAVIGATION",
"type": "tree",
"items": [
{"id": "dashboard", "label": "Dashboard", "icon": "dashboard"},
{"id": "lesson", "label": "Stundenmodus", "icon": "timer"},
{"id": "classes", "label": "Klassen", "icon": "groups"},
],
}
],
}
# === Schuljahres-Pfad ===
@router.get("/path")
async def get_schoolyear_path(teacher_id: str = Query(...)):
"""Generiert den Schuljahres-Pfad mit Meilensteinen."""
current_phase = "onboarding"
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherContextRepository(db)
context = repo.get_or_create(teacher_id)
current_phase = context.macro_phase.value
db.close()
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"},
{"id": "MS_SETUP", "label": "Einrichtung", "phase": "schuljahresstart"},
{"id": "MS_ROUTINE", "label": "Routinen", "phase": "unterrichtsaufbau"},
{"id": "MS_EXAM_1", "label": "Klausuren", "phase": "leistungsphase_1"},
{"id": "MS_HALFYEAR", "label": "Halbjahr", "phase": "halbjahresabschluss"},
{"id": "MS_EXAM_2", "label": "Pruefungen", "phase": "leistungsphase_2"},
{"id": "MS_END", "label": "Abschluss", "phase": "jahresabschluss"},
]
for i, milestone in enumerate(milestones):
phase_index = phase_order.index(milestone["phase"])
if phase_index < current_index:
milestone["status"] = "done"
elif phase_index == current_index:
milestone["status"] = "current"
else:
milestone["status"] = "upcoming"
return {
"milestones": milestones,
"current_milestone_id": milestones[current_index]["id"],
"progress_percent": int((current_index / (len(phase_order) - 1)) * 100),
}

View File

@@ -0,0 +1,271 @@
"""
Classroom API - Feedback Endpoints.
Endpoints fuer Lehrer-Feedback (Feature Request Tracking).
"""
from uuid import uuid4
from typing import Dict, List, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from .shared import init_db_if_needed, DB_ENABLED, logger
try:
from classroom_engine.database import SessionLocal
from classroom_engine.repository import TeacherFeedbackRepository
except ImportError:
pass
router = APIRouter(tags=["Feedback"])
# In-Memory Storage (Fallback)
_feedback: Dict[str, dict] = {}
# === Pydantic Models ===
class CreateFeedbackRequest(BaseModel):
"""Request zum Erstellen von Feedback."""
teacher_id: str
session_id: Optional[str] = None
category: str = Field(..., description="bug, feature, usability, content, other")
title: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., min_length=10, max_length=5000)
priority: str = Field("medium", description="low, medium, high, critical")
context_data: Optional[Dict[str, Any]] = None
class FeedbackResponse(BaseModel):
"""Response fuer ein Feedback."""
feedback_id: str
teacher_id: str
session_id: Optional[str]
category: str
title: str
description: str
priority: str
status: str
context_data: Optional[Dict[str, Any]]
admin_notes: Optional[str]
created_at: str
updated_at: Optional[str]
class FeedbackListResponse(BaseModel):
"""Response fuer Feedback-Liste."""
feedback: List[FeedbackResponse]
total_count: int
class FeedbackStatsResponse(BaseModel):
"""Response fuer Feedback-Statistiken."""
total: int
by_category: Dict[str, int]
by_status: Dict[str, int]
by_priority: Dict[str, int]
# === Endpoints ===
@router.post("/feedback", response_model=FeedbackResponse, status_code=201)
async def create_feedback(request: CreateFeedbackRequest) -> FeedbackResponse:
"""Erstellt ein neues Feedback."""
init_db_if_needed()
valid_categories = ["bug", "feature", "usability", "content", "other"]
if request.category not in valid_categories:
raise HTTPException(status_code=400, detail=f"Invalid category. Must be one of: {valid_categories}")
valid_priorities = ["low", "medium", "high", "critical"]
if request.priority not in valid_priorities:
raise HTTPException(status_code=400, detail=f"Invalid priority. Must be one of: {valid_priorities}")
feedback_id = str(uuid4())
now = datetime.utcnow()
feedback_data = {
"feedback_id": feedback_id,
"teacher_id": request.teacher_id,
"session_id": request.session_id,
"category": request.category,
"title": request.title,
"description": request.description,
"priority": request.priority,
"status": "open",
"context_data": request.context_data,
"admin_notes": None,
"created_at": now.isoformat(),
"updated_at": None,
}
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherFeedbackRepository(db)
repo.create(feedback_data)
db.close()
except Exception as e:
logger.warning(f"DB persist failed for feedback: {e}")
_feedback[feedback_id] = feedback_data
return FeedbackResponse(**feedback_data)
@router.get("/feedback", response_model=FeedbackListResponse)
async def list_feedback(
teacher_id: Optional[str] = Query(None),
category: Optional[str] = Query(None),
status: Optional[str] = Query(None),
priority: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0)
) -> FeedbackListResponse:
"""Listet Feedback (optional gefiltert)."""
init_db_if_needed()
feedback_list = []
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherFeedbackRepository(db)
db_feedback = repo.get_all(
teacher_id=teacher_id,
category=category,
status=status,
priority=priority,
limit=limit,
offset=offset
)
for fb in db_feedback:
feedback_list.append(FeedbackResponse(**fb))
total = repo.count(teacher_id=teacher_id, category=category, status=status)
db.close()
return FeedbackListResponse(feedback=feedback_list, total_count=total)
except Exception as e:
logger.warning(f"DB read failed for feedback: {e}")
# Fallback auf Memory
for fb in _feedback.values():
if teacher_id and fb["teacher_id"] != teacher_id:
continue
if category and fb["category"] != category:
continue
if status and fb["status"] != status:
continue
if priority and fb["priority"] != priority:
continue
feedback_list.append(FeedbackResponse(**fb))
total = len(feedback_list)
feedback_list = feedback_list[offset:offset + limit]
return FeedbackListResponse(feedback=feedback_list, total_count=total)
@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:
db = SessionLocal()
repo = TeacherFeedbackRepository(db)
stats = repo.get_stats()
db.close()
return FeedbackStatsResponse(**stats)
except Exception as e:
logger.warning(f"DB read failed for feedback stats: {e}")
# Fallback auf Memory
by_category: Dict[str, int] = {}
by_status: Dict[str, int] = {}
by_priority: Dict[str, int] = {}
for fb in _feedback.values():
cat = fb["category"]
by_category[cat] = by_category.get(cat, 0) + 1
st = fb["status"]
by_status[st] = by_status.get(st, 0) + 1
pr = fb["priority"]
by_priority[pr] = by_priority.get(pr, 0) + 1
return FeedbackStatsResponse(
total=len(_feedback),
by_category=by_category,
by_status=by_status,
by_priority=by_priority,
)
@router.get("/feedback/{feedback_id}")
async def get_feedback(feedback_id: str) -> FeedbackResponse:
"""Ruft ein einzelnes Feedback ab."""
init_db_if_needed()
if feedback_id in _feedback:
return FeedbackResponse(**_feedback[feedback_id])
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherFeedbackRepository(db)
fb = repo.get_by_id(feedback_id)
db.close()
if fb:
return FeedbackResponse(**fb)
except Exception as e:
logger.warning(f"DB read failed: {e}")
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="open, in_progress, resolved, closed, wont_fix")
) -> FeedbackResponse:
"""Aktualisiert den Status eines Feedbacks."""
init_db_if_needed()
valid_statuses = ["open", "in_progress", "resolved", "closed", "wont_fix"]
if status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
feedback_data = _feedback.get(feedback_id)
if not feedback_data and DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherFeedbackRepository(db)
feedback_data = repo.get_by_id(feedback_id)
db.close()
except Exception as e:
logger.warning(f"DB read failed: {e}")
if not feedback_data:
raise HTTPException(status_code=404, detail="Feedback nicht gefunden")
feedback_data["status"] = status
feedback_data["updated_at"] = datetime.utcnow().isoformat()
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherFeedbackRepository(db)
repo.update_status(feedback_id, status)
db.close()
except Exception as e:
logger.warning(f"DB update failed: {e}")
_feedback[feedback_id] = feedback_data
return FeedbackResponse(**feedback_data)

View File

@@ -0,0 +1,281 @@
"""
Classroom API - Homework Endpoints.
Endpoints fuer Hausaufgaben-Tracking (Feature f20).
"""
from uuid import uuid4
from typing import Dict, List, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from classroom_engine import Homework, HomeworkStatus
from .shared import init_db_if_needed, DB_ENABLED, logger
try:
from classroom_engine.database import SessionLocal
from classroom_engine.repository import HomeworkRepository
except ImportError:
pass
router = APIRouter(tags=["Homework"])
# In-Memory Storage (Fallback)
_homework: Dict[str, Homework] = {}
# === Pydantic Models ===
class CreateHomeworkRequest(BaseModel):
"""Request zum Erstellen einer Hausaufgabe."""
teacher_id: str
class_id: str
subject: str
title: str = Field(..., max_length=300)
description: str = ""
session_id: Optional[str] = None
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
class UpdateHomeworkRequest(BaseModel):
"""Request zum Aktualisieren einer Hausaufgabe."""
title: Optional[str] = Field(None, max_length=300)
description: Optional[str] = None
due_date: Optional[str] = None
status: Optional[str] = None
class HomeworkResponse(BaseModel):
"""Response fuer eine Hausaufgabe."""
homework_id: str
teacher_id: str
class_id: str
subject: str
title: str
description: str
session_id: Optional[str]
due_date: Optional[str]
status: str
is_overdue: bool
created_at: Optional[str]
updated_at: Optional[str]
class HomeworkListResponse(BaseModel):
"""Response fuer Liste von Hausaufgaben."""
homework: List[HomeworkResponse]
total: int
# === Helper Functions ===
def build_homework_response(hw: Homework) -> HomeworkResponse:
"""Baut eine HomeworkResponse aus einem Homework-Objekt."""
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=hw.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,
)
# === Endpoints ===
@router.post("/homework", response_model=HomeworkResponse, status_code=201)
async def create_homework(request: CreateHomeworkRequest) -> HomeworkResponse:
"""Erstellt eine neue Hausaufgabe (Feature f20)."""
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(),
)
if DB_ENABLED:
try:
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(...),
class_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
include_completed: bool = Query(False),
limit: int = Query(50, ge=1, le=100)
) -> HomeworkListResponse:
"""Listet Hausaufgaben eines Lehrers (Feature f20)."""
init_db_if_needed()
homework_list = []
if DB_ENABLED:
try:
db = SessionLocal()
repo = HomeworkRepository(db)
if class_id:
db_homework = repo.get_by_class(class_id, teacher_id, include_completed, limit)
else:
db_homework = repo.get_by_teacher(teacher_id, status, 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}")
for hw in _homework.values():
if hw.teacher_id != teacher_id:
continue
if class_id and hw.class_id != class_id:
continue
if status and hw.status.value != status:
continue
if not include_completed and hw.status == HomeworkStatus.COMPLETED:
continue
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()
if homework_id in _homework:
return build_homework_response(_homework[homework_id])
if DB_ENABLED:
try:
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()
homework = _homework.get(homework_id)
if not homework and DB_ENABLED:
try:
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")
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()
if DB_ENABLED:
try:
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(...)
) -> 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:
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,343 @@
"""
Classroom API - Materials Endpoints.
Endpoints fuer Unterrichtsmaterialien (Feature f19).
"""
from uuid import uuid4
from typing import Dict, List, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from classroom_engine import PhaseMaterial, MaterialType
from .shared import init_db_if_needed, DB_ENABLED, logger
try:
from classroom_engine.database import SessionLocal
from classroom_engine.repository import MaterialRepository
except ImportError:
pass
router = APIRouter(tags=["Materials"])
# In-Memory Storage (Fallback)
_materials: Dict[str, PhaseMaterial] = {}
# === Pydantic Models ===
class CreateMaterialRequest(BaseModel):
"""Request zum Erstellen eines Materials."""
teacher_id: str
title: str = Field(..., max_length=300)
material_type: str = Field("document")
url: Optional[str] = Field(None, max_length=2000)
description: str = ""
phase: Optional[str] = None
subject: str = ""
grade_level: str = ""
tags: List[str] = []
is_public: bool = False
session_id: Optional[str] = None
class UpdateMaterialRequest(BaseModel):
"""Request zum Aktualisieren eines Materials."""
title: Optional[str] = Field(None, max_length=300)
material_type: Optional[str] = None
url: Optional[str] = Field(None, max_length=2000)
description: Optional[str] = None
phase: Optional[str] = None
subject: Optional[str] = None
grade_level: Optional[str] = None
tags: Optional[List[str]] = None
is_public: Optional[bool] = None
class MaterialResponse(BaseModel):
"""Response fuer ein Material."""
material_id: str
teacher_id: str
title: str
material_type: str
url: Optional[str]
description: str
phase: Optional[str]
subject: str
grade_level: str
tags: List[str]
is_public: bool
usage_count: int
session_id: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
class MaterialListResponse(BaseModel):
"""Response fuer Liste von Materialien."""
materials: List[MaterialResponse]
total: int
# === Helper Functions ===
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,
)
# === Endpoints ===
@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(),
)
if DB_ENABLED:
try:
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(...),
phase: Optional[str] = Query(None),
subject: Optional[str] = Query(None),
include_public: bool = Query(True),
limit: int = Query(50, ge=1, le=100)
) -> MaterialListResponse:
"""Listet Materialien eines Lehrers (Feature f19)."""
init_db_if_needed()
materials_list = []
if DB_ENABLED:
try:
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}")
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(...),
subject: Optional[str] = Query(None),
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()
if material_id in _materials:
return build_material_response(_materials[material_id])
if DB_ENABLED:
try:
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()
material = _materials.get(material_id)
if not material and DB_ENABLED:
try:
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")
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()
if DB_ENABLED:
try:
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:
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:
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:
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,489 @@
"""
Classroom API - Pydantic Models.
Alle Request/Response Models fuer die Classroom API.
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
# === Request Models ===
class CreateSessionRequest(BaseModel):
"""Request zum Erstellen einer neuen Session."""
teacher_id: str = Field(..., description="ID des Lehrers")
class_id: str = Field(..., description="ID der Klasse")
subject: str = Field(..., description="Unterrichtsfach")
topic: Optional[str] = Field(None, description="Thema der Stunde")
phase_durations: Optional[Dict[str, int]] = Field(
None,
description="Optionale individuelle Phasendauern in Minuten"
)
class NotesRequest(BaseModel):
"""Request zum Aktualisieren von Notizen."""
notes: str = Field("", description="Stundennotizen")
homework: str = Field("", description="Hausaufgaben")
class ExtendTimeRequest(BaseModel):
"""Request zum Verlaengern der aktuellen Phase (Feature f28)."""
minutes: int = Field(5, ge=1, le=30, description="Zusaetzliche Minuten (1-30)")
# === Response Models ===
class PhaseInfo(BaseModel):
"""Informationen zu einer Phase."""
phase: str
display_name: str
icon: str
duration_minutes: int
is_completed: bool
is_current: bool
is_future: bool
class TimerStatus(BaseModel):
"""Timer-Status einer Phase."""
remaining_seconds: int
remaining_formatted: str
total_seconds: int
total_formatted: str
elapsed_seconds: int
elapsed_formatted: str
percentage_remaining: int
percentage_elapsed: int
percentage: int = Field(description="Alias fuer percentage_remaining (Visual Timer)")
warning: bool
overtime: bool
overtime_seconds: int
overtime_formatted: Optional[str]
is_paused: bool = Field(False, description="Ist der Timer pausiert?")
class SuggestionItem(BaseModel):
"""Ein Aktivitaets-Vorschlag."""
id: str
title: str
description: str
activity_type: str
estimated_minutes: int
icon: str
content_url: Optional[str]
class SessionResponse(BaseModel):
"""Vollstaendige Session-Response."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
current_phase: str
phase_display_name: str
phase_started_at: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
timer: TimerStatus
phases: List[PhaseInfo]
phase_history: List[Dict[str, Any]]
notes: str
homework: str
is_active: bool
is_ended: bool
is_paused: bool = Field(False, description="Ist die Stunde pausiert?")
class SuggestionsResponse(BaseModel):
"""Response fuer Vorschlaege."""
suggestions: List[SuggestionItem]
current_phase: str
phase_display_name: str
total_available: int
class PhasesListResponse(BaseModel):
"""Liste aller verfuegbaren Phasen."""
phases: List[Dict[str, Any]]
class ActiveSessionsResponse(BaseModel):
"""Liste aktiver Sessions."""
sessions: List[Dict[str, Any]]
count: int
# === Session History Models (Feature f17) ===
class SessionHistoryItem(BaseModel):
"""Ein Eintrag in der Session-Historie."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
lesson_started_at: Optional[str]
lesson_ended_at: Optional[str]
total_duration_minutes: int
phases_completed: int
notes: str
homework: str
class SessionHistoryResponse(BaseModel):
"""Response fuer Session-Historie."""
sessions: List[SessionHistoryItem]
total_count: int
page: int
page_size: int
# === Template Models ===
class TemplatePhaseConfig(BaseModel):
"""Konfiguration einer Phase im Template."""
phase: str
duration_minutes: int
activities: List[str] = Field(default_factory=list)
notes: str = ""
class CreateTemplateRequest(BaseModel):
"""Request zum Erstellen eines Templates."""
name: str = Field(..., min_length=1, max_length=100)
description: str = Field("", max_length=500)
subject: str = Field(..., min_length=1)
grade_level: Optional[str] = None
phase_configs: Optional[List[TemplatePhaseConfig]] = None
tags: List[str] = Field(default_factory=list)
is_public: bool = False
class UpdateTemplateRequest(BaseModel):
"""Request zum Aktualisieren eines Templates."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
subject: Optional[str] = None
grade_level: Optional[str] = None
phase_configs: Optional[List[TemplatePhaseConfig]] = None
tags: Optional[List[str]] = None
is_public: Optional[bool] = None
class TemplateResponse(BaseModel):
"""Response fuer ein einzelnes Template."""
template_id: str
name: str
description: str
subject: str
grade_level: Optional[str]
phase_configs: List[TemplatePhaseConfig]
tags: List[str]
is_public: bool
is_system: bool
created_by: str
created_at: str
updated_at: Optional[str]
usage_count: int
class TemplateListResponse(BaseModel):
"""Response fuer Template-Liste."""
templates: List[TemplateResponse]
total_count: int
class CreateFromTemplateRequest(BaseModel):
"""Request zum Erstellen einer Session aus Template."""
template_id: str
class_id: str
topic: Optional[str] = None
phase_duration_overrides: Optional[Dict[str, int]] = None
# === Homework Models ===
class CreateHomeworkRequest(BaseModel):
"""Request zum Erstellen einer Hausaufgabe."""
session_id: Optional[str] = None
teacher_id: str
class_id: str
subject: str
title: str = Field(..., min_length=1, max_length=200)
description: str = Field("", max_length=2000)
due_date: Optional[str] = None
estimated_minutes: Optional[int] = Field(None, ge=5, le=180)
materials: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
class UpdateHomeworkRequest(BaseModel):
"""Request zum Aktualisieren einer Hausaufgabe."""
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=2000)
due_date: Optional[str] = None
estimated_minutes: Optional[int] = Field(None, ge=5, le=180)
status: Optional[str] = None
materials: Optional[List[str]] = None
tags: Optional[List[str]] = None
class HomeworkResponse(BaseModel):
"""Response fuer eine Hausaufgabe."""
homework_id: str
session_id: Optional[str]
teacher_id: str
class_id: str
subject: str
title: str
description: str
due_date: Optional[str]
estimated_minutes: Optional[int]
status: str
materials: List[str]
tags: List[str]
created_at: str
updated_at: Optional[str]
class HomeworkListResponse(BaseModel):
"""Response fuer Hausaufgaben-Liste."""
homework: List[HomeworkResponse]
total_count: int
# === Material Models ===
class CreateMaterialRequest(BaseModel):
"""Request zum Erstellen eines Materials."""
teacher_id: str
title: str = Field(..., min_length=1, max_length=200)
description: str = Field("", max_length=1000)
material_type: str = Field(..., description="Type: link, document, video, interactive, image")
content_url: Optional[str] = None
content_data: Optional[Dict[str, Any]] = None
phase: Optional[str] = None
subject: Optional[str] = None
tags: List[str] = Field(default_factory=list)
is_public: bool = False
class UpdateMaterialRequest(BaseModel):
"""Request zum Aktualisieren eines Materials."""
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
material_type: Optional[str] = None
content_url: Optional[str] = None
content_data: Optional[Dict[str, Any]] = None
phase: Optional[str] = None
subject: Optional[str] = None
tags: Optional[List[str]] = None
is_public: Optional[bool] = None
class MaterialResponse(BaseModel):
"""Response fuer ein Material."""
material_id: str
teacher_id: str
title: str
description: str
material_type: str
content_url: Optional[str]
content_data: Optional[Dict[str, Any]]
phase: Optional[str]
subject: Optional[str]
tags: List[str]
is_public: bool
usage_count: int
created_at: str
updated_at: Optional[str]
class MaterialListResponse(BaseModel):
"""Response fuer Material-Liste."""
materials: List[MaterialResponse]
total_count: int
# === Feedback Models ===
class CreateFeedbackRequest(BaseModel):
"""Request zum Erstellen von Feedback."""
teacher_id: str
session_id: Optional[str] = None
category: str = Field(..., description="bug, feature, usability, content, other")
title: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., min_length=10, max_length=5000)
priority: str = Field("medium", description="low, medium, high, critical")
context_data: Optional[Dict[str, Any]] = None
class FeedbackResponse(BaseModel):
"""Response fuer ein Feedback."""
feedback_id: str
teacher_id: str
session_id: Optional[str]
category: str
title: str
description: str
priority: str
status: str
context_data: Optional[Dict[str, Any]]
admin_notes: Optional[str]
created_at: str
updated_at: Optional[str]
class FeedbackListResponse(BaseModel):
"""Response fuer Feedback-Liste."""
feedback: List[FeedbackResponse]
total_count: int
class FeedbackStatsResponse(BaseModel):
"""Response fuer Feedback-Statistiken."""
total: int
by_category: Dict[str, int]
by_status: Dict[str, int]
by_priority: Dict[str, int]
# === Settings Models ===
class PhaseDurationsUpdate(BaseModel):
"""Update fuer Phasendauern."""
einstieg: Optional[int] = Field(None, ge=1, le=30)
erarbeitung: Optional[int] = Field(None, ge=5, le=45)
sicherung: Optional[int] = Field(None, ge=3, le=20)
transfer: Optional[int] = Field(None, ge=3, le=20)
reflexion: Optional[int] = Field(None, ge=2, le=15)
class PreferencesUpdate(BaseModel):
"""Update fuer Lehrer-Praeferenzen."""
auto_advance: Optional[bool] = None
sound_enabled: Optional[bool] = None
notification_enabled: Optional[bool] = None
theme: Optional[str] = None
language: Optional[str] = None
class TeacherSettingsResponse(BaseModel):
"""Response fuer Lehrer-Einstellungen."""
teacher_id: str
phase_durations: Dict[str, int]
preferences: Dict[str, Any]
created_at: str
updated_at: Optional[str]
# === Analytics Models ===
class ReflectionRequest(BaseModel):
"""Request zum Erstellen/Aktualisieren einer Reflexion."""
session_id: str
teacher_id: str
overall_rating: int = Field(..., ge=1, le=5)
time_management_rating: int = Field(..., ge=1, le=5)
student_engagement_rating: int = Field(..., ge=1, le=5)
goals_achieved_rating: int = Field(..., ge=1, le=5)
what_worked_well: str = Field("", max_length=2000)
what_to_improve: str = Field("", max_length=2000)
notes_for_next_time: str = Field("", max_length=2000)
tags: List[str] = Field(default_factory=list)
class ReflectionResponse(BaseModel):
"""Response fuer eine Reflexion."""
reflection_id: str
session_id: str
teacher_id: str
overall_rating: int
time_management_rating: int
student_engagement_rating: int
goals_achieved_rating: int
what_worked_well: str
what_to_improve: str
notes_for_next_time: str
tags: List[str]
created_at: str
updated_at: Optional[str]
# === Teacher Context Models (v1 API) ===
class TeacherContextResponse(BaseModel):
"""Response fuer Teacher Context."""
teacher_id: str
federal_state: Optional[str]
school_type: Optional[str]
subjects: List[str]
class_levels: List[str]
current_macro_phase: Optional[str]
onboarding_completed: bool
preferences: Dict[str, Any]
created_at: str
updated_at: Optional[str]
class UpdateTeacherContextRequest(BaseModel):
"""Request zum Aktualisieren des Teacher Context."""
federal_state: Optional[str] = None
school_type: Optional[str] = None
subjects: Optional[List[str]] = None
class_levels: Optional[List[str]] = None
current_macro_phase: Optional[str] = None
preferences: Optional[Dict[str, Any]] = None
class EventResponse(BaseModel):
"""Response fuer ein Schuljahres-Event."""
event_id: str
teacher_id: str
title: str
event_type: str
start_date: str
end_date: Optional[str]
description: Optional[str]
status: str
metadata: Optional[Dict[str, Any]]
created_at: str
class CreateEventRequest(BaseModel):
"""Request zum Erstellen eines Events."""
title: str = Field(..., min_length=1, max_length=200)
event_type: str
start_date: str
end_date: Optional[str] = None
description: Optional[str] = Field(None, max_length=1000)
metadata: Optional[Dict[str, Any]] = None
class RoutineResponse(BaseModel):
"""Response fuer eine wiederkehrende Routine."""
routine_id: str
teacher_id: str
title: str
routine_type: str
recurrence_pattern: str
day_of_week: Optional[int]
time_of_day: Optional[str]
description: Optional[str]
is_active: bool
metadata: Optional[Dict[str, Any]]
created_at: str
class CreateRoutineRequest(BaseModel):
"""Request zum Erstellen einer Routine."""
title: str = Field(..., min_length=1, max_length=200)
routine_type: str
recurrence_pattern: str
day_of_week: Optional[int] = Field(None, ge=0, le=6)
time_of_day: Optional[str] = None
description: Optional[str] = Field(None, max_length=500)
metadata: Optional[Dict[str, Any]] = None

View File

@@ -0,0 +1,434 @@
"""
Classroom API - Session Endpoints.
Endpoints fuer Session-Management, Timer, Phasen-Kontrolle und History.
"""
from uuid import uuid4
from typing import Dict, List, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
SuggestionEngine,
)
from .models import (
CreateSessionRequest,
NotesRequest,
ExtendTimeRequest,
SessionResponse,
TimerStatus,
SuggestionItem,
SuggestionsResponse,
PhaseInfo,
SessionHistoryItem,
SessionHistoryResponse,
)
from .shared import (
init_db_if_needed,
get_session_or_404,
persist_session,
get_sessions,
add_session,
ws_manager,
DB_ENABLED,
logger,
)
# Database imports
try:
from classroom_engine.database import SessionLocal
from classroom_engine.repository import SessionRepository
except ImportError:
pass
router = APIRouter(tags=["Sessions"])
def build_session_response(session: LessonSession) -> SessionResponse:
"""Baut die vollstaendige Session-Response."""
fsm = LessonStateMachine()
timer = PhaseTimer()
timer_status = timer.get_phase_status(session)
phases_info = fsm.get_phases_info(session)
return SessionResponse(
session_id=session.session_id,
teacher_id=session.teacher_id,
class_id=session.class_id,
subject=session.subject,
topic=session.topic,
current_phase=session.current_phase.value,
phase_display_name=session.get_phase_display_name(),
phase_started_at=session.phase_started_at.isoformat() if session.phase_started_at else None,
lesson_started_at=session.lesson_started_at.isoformat() if session.lesson_started_at else None,
lesson_ended_at=session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
timer=TimerStatus(**timer_status),
phases=[PhaseInfo(**p) for p in phases_info],
phase_history=session.phase_history,
notes=session.notes,
homework=session.homework,
is_active=fsm.is_lesson_active(session),
is_ended=fsm.is_lesson_ended(session),
is_paused=session.is_paused,
)
async def notify_phase_change(session_id: str, phase: str, extra_data: dict = None):
"""Benachrichtigt WebSocket-Clients ueber Phasenwechsel."""
data = {"phase": phase}
if extra_data:
data.update(extra_data)
await ws_manager.broadcast_phase_change(session_id, data)
async def notify_session_ended(session_id: str):
"""Benachrichtigt WebSocket-Clients ueber Session-Ende."""
await ws_manager.broadcast_session_ended(session_id)
# === Session CRUD Endpoints ===
@router.post("/sessions", response_model=SessionResponse)
async def create_session(request: CreateSessionRequest) -> SessionResponse:
"""
Erstellt eine neue Unterrichtsstunde (Session).
Die Stunde ist nach Erstellung im Status NOT_STARTED.
Zum Starten muss /sessions/{id}/start aufgerufen werden.
"""
init_db_if_needed()
phase_durations = {
"einstieg": 8,
"erarbeitung": 20,
"sicherung": 10,
"transfer": 7,
"reflexion": 5,
}
if request.phase_durations:
phase_durations.update(request.phase_durations)
session = LessonSession(
session_id=str(uuid4()),
teacher_id=request.teacher_id,
class_id=request.class_id,
subject=request.subject,
topic=request.topic,
phase_durations=phase_durations,
)
add_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str) -> SessionResponse:
"""
Ruft den aktuellen Status einer Session ab.
Enthaelt alle Informationen inkl. Timer-Status und Phasen-Timeline.
"""
session = get_session_or_404(session_id)
return build_session_response(session)
@router.post("/sessions/{session_id}/start", response_model=SessionResponse)
async def start_lesson(session_id: str) -> SessionResponse:
"""
Startet die Unterrichtsstunde.
Wechselt von NOT_STARTED zur ersten Phase (EINSTIEG).
"""
session = get_session_or_404(session_id)
if session.current_phase != LessonPhase.NOT_STARTED:
raise HTTPException(
status_code=400,
detail=f"Stunde bereits gestartet (aktuelle Phase: {session.current_phase.value})"
)
fsm = LessonStateMachine()
session = fsm.transition(session, LessonPhase.EINSTIEG)
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/next-phase", response_model=SessionResponse)
async def next_phase(session_id: str) -> SessionResponse:
"""
Wechselt zur naechsten Phase.
Wirft 400 wenn keine naechste Phase verfuegbar (z.B. bei ENDED).
"""
session = get_session_or_404(session_id)
fsm = LessonStateMachine()
next_p = fsm.next_phase(session.current_phase)
if not next_p:
raise HTTPException(
status_code=400,
detail=f"Keine naechste Phase verfuegbar (aktuelle Phase: {session.current_phase.value})"
)
session = fsm.transition(session, next_p)
persist_session(session)
response = build_session_response(session)
await notify_phase_change(session_id, session.current_phase.value, {
"phase_display_name": session.get_phase_display_name(),
"is_ended": session.current_phase == LessonPhase.ENDED
})
return response
@router.post("/sessions/{session_id}/end", response_model=SessionResponse)
async def end_lesson(session_id: str) -> SessionResponse:
"""
Beendet die Unterrichtsstunde sofort.
Kann von jeder aktiven Phase aus aufgerufen werden.
"""
session = get_session_or_404(session_id)
if session.current_phase == LessonPhase.ENDED:
raise HTTPException(status_code=400, detail="Stunde bereits beendet")
if session.current_phase == LessonPhase.NOT_STARTED:
raise HTTPException(status_code=400, detail="Stunde noch nicht gestartet")
fsm = LessonStateMachine()
while session.current_phase != LessonPhase.ENDED:
next_p = fsm.next_phase(session.current_phase)
if next_p:
session = fsm.transition(session, next_p)
else:
break
persist_session(session)
await notify_session_ended(session_id)
return build_session_response(session)
# === Quick Actions (Feature f26/f27/f28) ===
@router.post("/sessions/{session_id}/pause", response_model=SessionResponse)
async def toggle_pause(session_id: str) -> SessionResponse:
"""
Pausiert oder setzt die laufende Stunde fort (Feature f27).
Toggle-Funktion: Wenn pausiert -> fortsetzen, wenn laufend -> pausieren.
"""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
if session.is_paused:
if session.pause_started_at:
pause_duration = (datetime.utcnow() - session.pause_started_at).total_seconds()
session.total_paused_seconds += int(pause_duration)
session.is_paused = False
session.pause_started_at = None
else:
session.is_paused = True
session.pause_started_at = datetime.utcnow()
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/extend", response_model=SessionResponse)
async def extend_phase(session_id: str, request: ExtendTimeRequest) -> SessionResponse:
"""
Verlaengert die aktuelle Phase um zusaetzliche Minuten (Feature f28).
"""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
phase_id = session.current_phase.value
current_duration = session.phase_durations.get(phase_id, 10)
session.phase_durations[phase_id] = current_duration + request.minutes
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}/timer", response_model=TimerStatus)
async def get_timer(session_id: str) -> TimerStatus:
"""
Ruft den Timer-Status der aktuellen Phase ab.
"""
session = get_session_or_404(session_id)
timer = PhaseTimer()
status = timer.get_phase_status(session)
return TimerStatus(**status)
@router.get("/sessions/{session_id}/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(
session_id: str,
limit: int = Query(3, ge=1, le=10, description="Anzahl Vorschlaege")
) -> SuggestionsResponse:
"""
Ruft phasenspezifische Aktivitaets-Vorschlaege ab.
"""
session = get_session_or_404(session_id)
engine = SuggestionEngine()
response = engine.get_suggestions_response(session, limit)
return SuggestionsResponse(
suggestions=[SuggestionItem(**s) for s in response["suggestions"]],
current_phase=response["current_phase"],
phase_display_name=response["phase_display_name"],
total_available=response["total_available"],
)
@router.put("/sessions/{session_id}/notes", response_model=SessionResponse)
async def update_notes(session_id: str, request: NotesRequest) -> SessionResponse:
"""
Aktualisiert Notizen und Hausaufgaben der Stunde.
"""
session = get_session_or_404(session_id)
session.notes = request.notes
session.homework = request.homework
persist_session(session)
return build_session_response(session)
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> Dict[str, str]:
"""
Loescht eine Session.
"""
sessions = get_sessions()
if session_id not in sessions:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
del sessions[session_id]
if DB_ENABLED:
try:
db = SessionLocal()
repo = SessionRepository(db)
repo.delete(session_id)
db.close()
except Exception as e:
logger.error(f"Failed to delete session {session_id} from DB: {e}")
return {"status": "deleted", "session_id": session_id}
# === Session History (Feature f17) ===
@router.get("/history/{teacher_id}", response_model=SessionHistoryResponse)
async def get_session_history(
teacher_id: str,
limit: int = Query(20, ge=1, le=100, description="Max. Anzahl Eintraege"),
offset: int = Query(0, ge=0, description="Offset fuer Pagination")
) -> SessionHistoryResponse:
"""
Ruft die Session-History eines Lehrers ab (Feature f17).
"""
init_db_if_needed()
sessions = get_sessions()
if not DB_ENABLED:
ended_sessions = [
s for s in sessions.values()
if s.teacher_id == teacher_id and s.current_phase == LessonPhase.ENDED
]
ended_sessions.sort(
key=lambda x: x.lesson_ended_at or datetime.min,
reverse=True
)
paginated = ended_sessions[offset:offset + limit]
items = []
for s in paginated:
duration = None
if s.lesson_started_at and s.lesson_ended_at:
duration = int((s.lesson_ended_at - s.lesson_started_at).total_seconds() / 60)
items.append(SessionHistoryItem(
session_id=s.session_id,
teacher_id=s.teacher_id,
class_id=s.class_id,
subject=s.subject,
topic=s.topic,
lesson_started_at=s.lesson_started_at.isoformat() if s.lesson_started_at else None,
lesson_ended_at=s.lesson_ended_at.isoformat() if s.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(s.phase_history),
notes=s.notes,
homework=s.homework,
))
return SessionHistoryResponse(
sessions=items,
total_count=len(ended_sessions),
page=offset // limit + 1,
page_size=limit,
)
try:
db = SessionLocal()
repo = SessionRepository(db)
db_sessions = repo.get_history_by_teacher(teacher_id, limit, offset)
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
total_count = db.query(LessonSessionDB).filter(
LessonSessionDB.teacher_id == teacher_id,
LessonSessionDB.current_phase == LessonPhaseEnum.ENDED
).count()
items = []
for db_session in db_sessions:
duration = None
if db_session.lesson_started_at and db_session.lesson_ended_at:
duration = int((db_session.lesson_ended_at - db_session.lesson_started_at).total_seconds() / 60)
phase_history = db_session.phase_history or []
items.append(SessionHistoryItem(
session_id=db_session.id,
teacher_id=db_session.teacher_id,
class_id=db_session.class_id,
subject=db_session.subject,
topic=db_session.topic,
lesson_started_at=db_session.lesson_started_at.isoformat() if db_session.lesson_started_at else None,
lesson_ended_at=db_session.lesson_ended_at.isoformat() if db_session.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(phase_history),
notes=db_session.notes or "",
homework=db_session.homework or "",
))
db.close()
return SessionHistoryResponse(
sessions=items,
total_count=total_count,
page=offset // limit + 1,
page_size=limit,
)
except Exception as e:
logger.error(f"Failed to get session history: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Laden der History")

View File

@@ -0,0 +1,201 @@
"""
Classroom API - Settings Endpoints.
Endpoints fuer Lehrer-Einstellungen.
"""
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from classroom_engine import get_default_durations
from .shared import init_db_if_needed, DB_ENABLED, logger
try:
from classroom_engine.database import SessionLocal
from classroom_engine.repository import TeacherSettingsRepository
except ImportError:
pass
router = APIRouter(tags=["Settings"])
# In-Memory Storage (Fallback)
_settings: Dict[str, dict] = {}
# === Pydantic Models ===
class PhaseDurationsUpdate(BaseModel):
"""Update fuer Phasendauern."""
einstieg: Optional[int] = Field(None, ge=1, le=30)
erarbeitung: Optional[int] = Field(None, ge=5, le=45)
sicherung: Optional[int] = Field(None, ge=3, le=20)
transfer: Optional[int] = Field(None, ge=3, le=20)
reflexion: Optional[int] = Field(None, ge=2, le=15)
class PreferencesUpdate(BaseModel):
"""Update fuer Lehrer-Praeferenzen."""
auto_advance: Optional[bool] = None
sound_enabled: Optional[bool] = None
notification_enabled: Optional[bool] = None
theme: Optional[str] = None
language: Optional[str] = None
class TeacherSettingsResponse(BaseModel):
"""Response fuer Lehrer-Einstellungen."""
teacher_id: str
phase_durations: Dict[str, int]
preferences: Dict[str, Any]
created_at: str
updated_at: Optional[str]
# === Helper Functions ===
def get_default_settings(teacher_id: str) -> dict:
"""Gibt die Default-Einstellungen zurueck."""
return {
"teacher_id": teacher_id,
"phase_durations": get_default_durations(),
"preferences": {
"auto_advance": False,
"sound_enabled": True,
"notification_enabled": True,
"theme": "light",
"language": "de",
},
"created_at": datetime.utcnow().isoformat(),
"updated_at": None,
}
# === Endpoints ===
@router.get("/settings/{teacher_id}", response_model=TeacherSettingsResponse)
async def get_teacher_settings(teacher_id: str) -> TeacherSettingsResponse:
"""Ruft die Einstellungen eines Lehrers ab."""
init_db_if_needed()
# Aus Memory pruefen
if teacher_id in _settings:
return TeacherSettingsResponse(**_settings[teacher_id])
# Aus DB laden
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherSettingsRepository(db)
db_settings = repo.get_by_teacher(teacher_id)
db.close()
if db_settings:
settings_data = repo.to_dict(db_settings)
_settings[teacher_id] = settings_data
return TeacherSettingsResponse(**settings_data)
except Exception as e:
logger.warning(f"DB read failed for settings: {e}")
# Default-Einstellungen erstellen
settings_data = get_default_settings(teacher_id)
_settings[teacher_id] = settings_data
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherSettingsRepository(db)
repo.create(settings_data)
db.close()
except Exception as e:
logger.warning(f"DB persist failed for settings: {e}")
return TeacherSettingsResponse(**settings_data)
@router.put("/settings/{teacher_id}/durations", response_model=TeacherSettingsResponse)
async def update_phase_durations(
teacher_id: str,
request: PhaseDurationsUpdate
) -> TeacherSettingsResponse:
"""Aktualisiert die Phasendauern eines Lehrers."""
init_db_if_needed()
# Aktuelle Einstellungen laden
current = await get_teacher_settings(teacher_id)
settings_data = _settings.get(teacher_id, get_default_settings(teacher_id))
# Nur uebergebene Werte aktualisieren
durations = settings_data["phase_durations"]
if request.einstieg is not None:
durations["einstieg"] = request.einstieg
if request.erarbeitung is not None:
durations["erarbeitung"] = request.erarbeitung
if request.sicherung is not None:
durations["sicherung"] = request.sicherung
if request.transfer is not None:
durations["transfer"] = request.transfer
if request.reflexion is not None:
durations["reflexion"] = request.reflexion
settings_data["phase_durations"] = durations
settings_data["updated_at"] = datetime.utcnow().isoformat()
# In DB speichern
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherSettingsRepository(db)
repo.update_durations(teacher_id, durations)
db.close()
except Exception as e:
logger.warning(f"DB update failed for durations: {e}")
_settings[teacher_id] = settings_data
return TeacherSettingsResponse(**settings_data)
@router.put("/settings/{teacher_id}/preferences", response_model=TeacherSettingsResponse)
async def update_preferences(
teacher_id: str,
request: PreferencesUpdate
) -> TeacherSettingsResponse:
"""Aktualisiert die Praeferenzen eines Lehrers."""
init_db_if_needed()
# Aktuelle Einstellungen laden
current = await get_teacher_settings(teacher_id)
settings_data = _settings.get(teacher_id, get_default_settings(teacher_id))
# Nur uebergebene Werte aktualisieren
prefs = settings_data["preferences"]
if request.auto_advance is not None:
prefs["auto_advance"] = request.auto_advance
if request.sound_enabled is not None:
prefs["sound_enabled"] = request.sound_enabled
if request.notification_enabled is not None:
prefs["notification_enabled"] = request.notification_enabled
if request.theme is not None:
prefs["theme"] = request.theme
if request.language is not None:
prefs["language"] = request.language
settings_data["preferences"] = prefs
settings_data["updated_at"] = datetime.utcnow().isoformat()
# In DB speichern
if DB_ENABLED:
try:
db = SessionLocal()
repo = TeacherSettingsRepository(db)
repo.update_preferences(teacher_id, prefs)
db.close()
except Exception as e:
logger.warning(f"DB update failed for preferences: {e}")
_settings[teacher_id] = settings_data
return TeacherSettingsResponse(**settings_data)

View File

@@ -0,0 +1,341 @@
"""
Classroom API - Shared State und Helper Functions.
Zentrale Komponenten die von allen Classroom-Modulen verwendet werden.
"""
from typing import Dict, List, Optional, Any
from datetime import datetime
import os
import logging
import asyncio
import json
from fastapi import HTTPException, WebSocket, Request
# Auth imports (Phase 7: Keycloak Integration)
try:
from auth import get_current_user
AUTH_ENABLED = True
except ImportError:
AUTH_ENABLED = False
logging.warning("Auth module not available, using demo user fallback")
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
)
# Database imports (Feature f22)
try:
from classroom_engine.database import get_db, init_db, SessionLocal
from classroom_engine.repository import SessionRepository
DB_ENABLED = True
except ImportError:
DB_ENABLED = False
logging.warning("Classroom DB not available, using in-memory storage only")
logger = logging.getLogger(__name__)
# === WebSocket Connection Manager (Phase 6: Real-time) ===
class ConnectionManager:
"""
Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates.
Features:
- Session-basierte Verbindungen (jede Session hat eigene Clients)
- Automatisches Cleanup bei Disconnect
- Broadcast an alle Clients einer Session
- Multi-Device Support
"""
def __init__(self):
# session_id -> Set[WebSocket]
self._connections: Dict[str, set] = {}
# WebSocket -> session_id (reverse lookup)
self._websocket_sessions: Dict[WebSocket, str] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, session_id: str):
"""Verbindet einen Client mit einer Session."""
await websocket.accept()
async with self._lock:
if session_id not in self._connections:
self._connections[session_id] = set()
self._connections[session_id].add(websocket)
self._websocket_sessions[websocket] = session_id
logger.info(f"WebSocket connected to session {session_id}, total clients: {len(self._connections[session_id])}")
async def disconnect(self, websocket: WebSocket):
"""Trennt einen Client."""
async with self._lock:
session_id = self._websocket_sessions.pop(websocket, None)
if session_id and session_id in self._connections:
self._connections[session_id].discard(websocket)
if not self._connections[session_id]:
del self._connections[session_id]
logger.info(f"WebSocket disconnected from session {session_id}")
async def broadcast_to_session(self, session_id: str, message: dict):
"""Sendet eine Nachricht an alle Clients einer Session."""
async with self._lock:
connections = self._connections.get(session_id, set()).copy()
if not connections:
return
message_json = json.dumps(message)
dead_connections = []
for websocket in connections:
try:
await websocket.send_text(message_json)
except Exception as e:
logger.warning(f"Failed to send to websocket: {e}")
dead_connections.append(websocket)
# Cleanup dead connections
for ws in dead_connections:
await self.disconnect(ws)
async def broadcast_timer_update(self, session_id: str, timer_data: dict):
"""Sendet Timer-Update an alle Clients einer Session."""
await self.broadcast_to_session(session_id, {
"type": "timer_update",
"data": timer_data
})
async def broadcast_phase_change(self, session_id: str, phase_data: dict):
"""Sendet Phasenwechsel-Event an alle Clients."""
await self.broadcast_to_session(session_id, {
"type": "phase_change",
"data": phase_data
})
async def broadcast_session_ended(self, session_id: str):
"""Sendet Session-Ende-Event an alle Clients."""
await self.broadcast_to_session(session_id, {
"type": "session_ended",
"data": {"session_id": session_id}
})
def get_client_count(self, session_id: str) -> int:
"""Gibt die Anzahl der verbundenen Clients fuer eine Session zurueck."""
return len(self._connections.get(session_id, set()))
def get_active_sessions(self) -> List[str]:
"""Gibt alle Sessions mit aktiven WebSocket-Verbindungen zurueck."""
return list(self._connections.keys())
# Global instances
ws_manager = ConnectionManager()
_sessions: Dict[str, LessonSession] = {}
_db_initialized = False
_timer_broadcast_task: Optional[asyncio.Task] = None
# === Demo User ===
DEMO_USER = {
"user_id": "demo-teacher",
"email": "demo@breakpilot.app",
"name": "Demo Lehrer",
"given_name": "Demo",
"family_name": "Lehrer",
"role": "teacher",
"is_demo": True
}
# === Timer Broadcast Functions ===
async def _timer_broadcast_loop():
"""
Hintergrund-Task der Timer-Updates alle 1 Sekunde an verbundene Clients sendet.
"""
logger.info("Timer broadcast loop started")
while True:
try:
await asyncio.sleep(1)
active_ws_sessions = ws_manager.get_active_sessions()
if not active_ws_sessions:
continue
for session_id in active_ws_sessions:
session = _sessions.get(session_id)
if not session or session.is_ended:
continue
timer_status = build_timer_status(session)
await ws_manager.broadcast_timer_update(session_id, timer_status)
except asyncio.CancelledError:
logger.info("Timer broadcast loop cancelled")
break
except Exception as e:
logger.error(f"Error in timer broadcast loop: {e}")
await asyncio.sleep(5)
def start_timer_broadcast():
"""Startet den Timer-Broadcast-Task wenn noch nicht laufend."""
global _timer_broadcast_task
if _timer_broadcast_task is None or _timer_broadcast_task.done():
_timer_broadcast_task = asyncio.create_task(_timer_broadcast_loop())
logger.info("Timer broadcast task created")
def stop_timer_broadcast():
"""Stoppt den Timer-Broadcast-Task."""
global _timer_broadcast_task
if _timer_broadcast_task and not _timer_broadcast_task.done():
_timer_broadcast_task.cancel()
logger.info("Timer broadcast task cancelled")
# === Database Functions ===
def init_db_if_needed():
"""Initialisiert DB und laedt aktive Sessions beim ersten Aufruf."""
global _db_initialized
if _db_initialized or not DB_ENABLED:
return
try:
init_db()
_load_active_sessions_from_db()
_db_initialized = True
logger.info("Classroom DB initialized, loaded active sessions")
except Exception as e:
logger.error(f"Failed to initialize Classroom DB: {e}")
def _load_active_sessions_from_db():
"""Laedt alle aktiven Sessions aus der DB in den Memory-Cache."""
if not DB_ENABLED:
return
try:
db = SessionLocal()
repo = SessionRepository(db)
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
active_db_sessions = db.query(LessonSessionDB).filter(
LessonSessionDB.current_phase != LessonPhaseEnum.ENDED
).all()
for db_session in active_db_sessions:
session = repo.to_dataclass(db_session)
_sessions[session.session_id] = session
logger.info(f"Loaded session {session.session_id} from DB")
db.close()
except Exception as e:
logger.error(f"Failed to load sessions from DB: {e}")
def persist_session(session: LessonSession):
"""Speichert/aktualisiert Session in der DB."""
if not DB_ENABLED:
return
try:
db = SessionLocal()
repo = SessionRepository(db)
existing = repo.get_by_id(session.session_id)
if existing:
repo.update(session)
else:
repo.create(session)
db.close()
except Exception as e:
logger.error(f"Failed to persist session {session.session_id}: {e}")
# === Auth Functions ===
async def get_optional_current_user(request: Request) -> Dict[str, Any]:
"""
Optionale Authentifizierung - gibt Demo-User zurueck wenn kein Token.
"""
if not AUTH_ENABLED:
return DEMO_USER
auth_header = request.headers.get("Authorization", "")
if not auth_header or not auth_header.startswith("Bearer "):
env = os.environ.get("ENVIRONMENT", "development")
if env == "development":
return DEMO_USER
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
try:
return await get_current_user(request)
except Exception as e:
logger.warning(f"Auth failed: {e}")
env = os.environ.get("ENVIRONMENT", "development")
if env == "development":
return DEMO_USER
raise HTTPException(status_code=401, detail="Authentifizierung fehlgeschlagen")
# === Session Helpers ===
def get_session_or_404(session_id: str) -> LessonSession:
"""Holt eine Session oder wirft 404. Prueft auch DB bei Cache-Miss."""
init_db_if_needed()
session = _sessions.get(session_id)
if session:
return session
if DB_ENABLED:
try:
db = SessionLocal()
repo = SessionRepository(db)
db_session = repo.get_by_id(session_id)
if db_session:
session = repo.to_dataclass(db_session)
_sessions[session.session_id] = session
db.close()
return session
db.close()
except Exception as e:
logger.error(f"Failed to load session {session_id} from DB: {e}")
raise HTTPException(status_code=404, detail="Session nicht gefunden")
def build_timer_status(session: LessonSession) -> dict:
"""Baut Timer-Status als dict fuer WebSocket-Broadcast."""
timer = PhaseTimer()
status = timer.get_phase_status(session)
status["session_id"] = session.session_id
status["current_phase"] = session.current_phase.value
status["is_paused"] = session.is_paused
status["timestamp"] = datetime.utcnow().isoformat()
return status
def get_sessions() -> Dict[str, LessonSession]:
"""Gibt das Sessions-Dictionary zurueck."""
return _sessions
def add_session(session: LessonSession):
"""Fuegt eine Session zum Cache hinzu und persistiert sie."""
_sessions[session.session_id] = session
persist_session(session)
def remove_session(session_id: str):
"""Entfernt eine Session aus dem Cache."""
_sessions.pop(session_id, None)

View File

@@ -0,0 +1,392 @@
"""
Classroom API - Template Endpoints.
Endpoints fuer Stunden-Vorlagen (Feature f37).
"""
from uuid import uuid4
from typing import Dict, List, Optional
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from classroom_engine import (
LessonSession,
LessonTemplate,
SYSTEM_TEMPLATES,
get_default_durations,
)
from .models import SessionResponse
from .shared import (
init_db_if_needed,
get_sessions,
persist_session,
DB_ENABLED,
logger,
)
from .sessions import build_session_response
try:
from classroom_engine.database import SessionLocal
from classroom_engine.repository import TemplateRepository
except ImportError:
pass
router = APIRouter(tags=["Templates"])
# === Pydantic Models ===
class TemplateCreate(BaseModel):
"""Request zum Erstellen einer Vorlage."""
name: str = Field(..., min_length=1, max_length=200)
description: str = Field("", max_length=1000)
subject: str = Field("", max_length=100)
grade_level: str = Field("", max_length=50)
phase_durations: Dict[str, int] = Field(default_factory=get_default_durations)
default_topic: str = Field("", max_length=500)
default_notes: str = Field("")
is_public: bool = Field(False)
class TemplateUpdate(BaseModel):
"""Request zum Aktualisieren einer Vorlage."""
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
subject: Optional[str] = Field(None, max_length=100)
grade_level: Optional[str] = Field(None, max_length=50)
phase_durations: Optional[Dict[str, int]] = None
default_topic: Optional[str] = Field(None, max_length=500)
default_notes: Optional[str] = None
is_public: Optional[bool] = None
class TemplateResponse(BaseModel):
"""Response fuer eine einzelne Vorlage."""
template_id: str
teacher_id: str
name: str
description: str
subject: str
grade_level: str
phase_durations: Dict[str, int]
default_topic: str
default_notes: str
is_public: bool
usage_count: int
total_duration_minutes: int
created_at: Optional[str]
updated_at: Optional[str]
is_system_template: bool = False
class TemplateListResponse(BaseModel):
"""Response fuer Template-Liste."""
templates: List[TemplateResponse]
total_count: int
# === Helper Functions ===
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
# === Endpoints ===
@router.get("/templates", response_model=TemplateListResponse)
async def list_templates(
teacher_id: Optional[str] = Query(None),
subject: Optional[str] = Query(None),
include_system: bool = Query(True)
) -> TemplateListResponse:
"""Listet verfuegbare Stunden-Vorlagen (Feature f37)."""
init_db_if_needed()
templates: List[TemplateResponse] = []
if include_system:
templates.extend(get_system_templates())
if DB_ENABLED:
try:
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()
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)
if DB_ENABLED:
try:
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(...)
) -> TemplateResponse:
"""Erstellt eine neue Stunden-Vorlage."""
init_db_if_needed()
if not DB_ENABLED:
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
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=request.phase_durations,
default_topic=request.default_topic,
default_notes=request.default_notes,
is_public=request.is_public,
created_at=datetime.utcnow(),
)
try:
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(...)
) -> TemplateResponse:
"""Aktualisiert eine Stunden-Vorlage."""
init_db_if_needed()
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:
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")
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(...)
) -> Dict[str, str]:
"""Loescht eine Stunden-Vorlage."""
init_db_if_needed()
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:
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(...),
teacher_id: str = Query(...),
class_id: str = Query(...),
topic: Optional[str] = Query(None)
) -> SessionResponse:
"""Erstellt eine neue Session basierend auf einer Vorlage."""
init_db_if_needed()
template_data = None
is_system = False
for t in SYSTEM_TEMPLATES:
if t["template_id"] == template_id:
template_data = t
is_system = True
break
if not template_data and DB_ENABLED:
try:
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 "",
}
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 = 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 = get_sessions()
sessions[session.session_id] = session
persist_session(session)
return build_session_response(session)

View File

@@ -0,0 +1,185 @@
"""
Classroom API - Utility Endpoints.
Health-Check, Phasen-Liste und andere Utility-Endpoints.
"""
from typing import Dict, List, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import HTMLResponse
from sqlalchemy import text
from pydantic import BaseModel
from classroom_engine import LESSON_PHASES, LessonStateMachine
from .shared import (
init_db_if_needed,
get_sessions,
get_session_or_404,
ws_manager,
DB_ENABLED,
logger,
)
try:
from classroom_engine.database import SessionLocal
except ImportError:
pass
router = APIRouter(tags=["Utility"])
# === Pydantic Models ===
class PhasesListResponse(BaseModel):
"""Liste aller verfuegbaren Phasen."""
phases: List[Dict[str, Any]]
class ActiveSessionsResponse(BaseModel):
"""Liste aktiver Sessions."""
sessions: List[Dict[str, Any]]
count: int
# === 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)
) -> ActiveSessionsResponse:
"""Listet alle (optionally gefilterten) Sessions."""
sessions = get_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."""
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)}"
sessions = get_sessions()
return {
"status": "healthy",
"service": "classroom-engine",
"active_sessions": len(sessions),
"db_enabled": DB_ENABLED,
"db_status": db_status,
"websocket_connections": sum(
ws_manager.get_client_count(sid) for sid in ws_manager.get_active_sessions()
),
"timestamp": datetime.utcnow().isoformat(),
}
@router.get("/ws/status")
async def websocket_status() -> Dict[str, Any]:
"""Status der WebSocket-Verbindungen."""
active_sessions = ws_manager.get_active_sessions()
session_counts = {
sid: ws_manager.get_client_count(sid) for sid in active_sessions
}
return {
"active_sessions": len(active_sessions),
"session_connections": session_counts,
"total_connections": sum(session_counts.values()),
"timestamp": datetime.utcnow().isoformat(),
}
@router.get("/export/session/{session_id}", response_class=HTMLResponse)
async def export_session_html(session_id: str) -> HTMLResponse:
"""Exportiert eine Session als HTML-Dokument."""
session = get_session_or_404(session_id)
# Einfacher HTML-Export
html = f"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Session Export - {session.subject}</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }}
h1 {{ color: #333; }}
.meta {{ color: #666; margin-bottom: 20px; }}
.section {{ margin: 20px 0; padding: 15px; background: #f5f5f5; border-radius: 8px; }}
.phase {{ display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #ddd; }}
</style>
</head>
<body>
<h1>{session.subject}: {session.topic or 'Ohne Thema'}</h1>
<div class="meta">
<p>Klasse: {session.class_id}</p>
<p>Datum: {session.lesson_started_at.strftime('%d.%m.%Y %H:%M') if session.lesson_started_at else 'Nicht gestartet'}</p>
<p>Status: {session.current_phase.value}</p>
</div>
<div class="section">
<h2>Phasen</h2>
{"".join(f'<div class="phase"><span>{p.get("phase", "")}</span><span>{p.get("duration_seconds", 0) // 60} min</span></div>' for p in session.phase_history)}
</div>
<div class="section">
<h2>Notizen</h2>
<p>{session.notes or 'Keine Notizen'}</p>
</div>
<div class="section">
<h2>Hausaufgaben</h2>
<p>{session.homework or 'Keine Hausaufgaben'}</p>
</div>
<footer style="margin-top: 40px; color: #999; font-size: 12px;">
Exportiert am {datetime.utcnow().strftime('%d.%m.%Y %H:%M')} UTC - BreakPilot Classroom
</footer>
</body>
</html>
"""
return HTMLResponse(content=html)