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:
110
backend/classroom/__init__.py
Normal file
110
backend/classroom/__init__.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Classroom API Module
|
||||
|
||||
A modular classroom management API for lesson sessions, templates,
|
||||
homework, materials, analytics, and real-time features.
|
||||
|
||||
Usage:
|
||||
from classroom import router as classroom_router
|
||||
app.include_router(classroom_router, prefix="/api/classroom")
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .routes.sessions import router as sessions_router
|
||||
from .routes.templates import router as templates_router
|
||||
from .routes.homework import router as homework_router
|
||||
from .routes.materials import router as materials_router
|
||||
from .routes.analytics import router as analytics_router
|
||||
from .routes.export import router as export_router
|
||||
from .routes.feedback import router as feedback_router
|
||||
from .routes.settings import router as settings_router
|
||||
from .routes.context import router as context_router
|
||||
from .routes.websocket_routes import router as websocket_router
|
||||
|
||||
# Create the main router that combines all sub-routers
|
||||
router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(sessions_router)
|
||||
router.include_router(templates_router)
|
||||
router.include_router(homework_router)
|
||||
router.include_router(materials_router)
|
||||
router.include_router(analytics_router)
|
||||
router.include_router(export_router)
|
||||
router.include_router(feedback_router)
|
||||
router.include_router(settings_router)
|
||||
router.include_router(context_router)
|
||||
router.include_router(websocket_router)
|
||||
|
||||
# Re-export commonly used items
|
||||
from .models import (
|
||||
# Session Models
|
||||
CreateSessionRequest,
|
||||
SessionResponse,
|
||||
TimerStatus,
|
||||
# Template Models
|
||||
TemplateCreate,
|
||||
TemplateResponse,
|
||||
# Homework Models
|
||||
CreateHomeworkRequest,
|
||||
HomeworkResponse,
|
||||
# Material Models
|
||||
CreateMaterialRequest,
|
||||
MaterialResponse,
|
||||
# Analytics Models
|
||||
SessionSummaryResponse,
|
||||
TeacherAnalyticsResponse,
|
||||
ReflectionCreate,
|
||||
ReflectionResponse,
|
||||
# Feedback Models
|
||||
FeedbackCreate,
|
||||
FeedbackResponse,
|
||||
# Settings Models
|
||||
TeacherSettingsResponse,
|
||||
# Context Models
|
||||
TeacherContextResponse,
|
||||
)
|
||||
|
||||
from .services.persistence import (
|
||||
sessions,
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
)
|
||||
|
||||
from .websocket_manager import (
|
||||
ws_manager,
|
||||
notify_phase_change,
|
||||
notify_session_ended,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main router
|
||||
"router",
|
||||
# Session storage
|
||||
"sessions",
|
||||
"init_db_if_needed",
|
||||
"DB_ENABLED",
|
||||
# WebSocket
|
||||
"ws_manager",
|
||||
"notify_phase_change",
|
||||
"notify_session_ended",
|
||||
# Models
|
||||
"CreateSessionRequest",
|
||||
"SessionResponse",
|
||||
"TimerStatus",
|
||||
"TemplateCreate",
|
||||
"TemplateResponse",
|
||||
"CreateHomeworkRequest",
|
||||
"HomeworkResponse",
|
||||
"CreateMaterialRequest",
|
||||
"MaterialResponse",
|
||||
"SessionSummaryResponse",
|
||||
"TeacherAnalyticsResponse",
|
||||
"ReflectionCreate",
|
||||
"ReflectionResponse",
|
||||
"FeedbackCreate",
|
||||
"FeedbackResponse",
|
||||
"TeacherSettingsResponse",
|
||||
"TeacherContextResponse",
|
||||
]
|
||||
568
backend/classroom/models.py
Normal file
568
backend/classroom/models.py
Normal file
@@ -0,0 +1,568 @@
|
||||
"""
|
||||
Classroom API - Pydantic Models
|
||||
|
||||
Alle Request- und Response-Models fuer die Classroom API.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# === Session Models ===
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
"""Request zum Erstellen einer neuen Session."""
|
||||
teacher_id: str = Field(..., description="ID des Lehrers")
|
||||
class_id: str = Field(..., description="ID der Klasse")
|
||||
subject: str = Field(..., description="Unterrichtsfach")
|
||||
topic: Optional[str] = Field(None, description="Thema der Stunde")
|
||||
phase_durations: Optional[Dict[str, int]] = Field(
|
||||
None,
|
||||
description="Optionale individuelle Phasendauern in Minuten"
|
||||
)
|
||||
|
||||
|
||||
class NotesRequest(BaseModel):
|
||||
"""Request zum Aktualisieren von Notizen."""
|
||||
notes: str = Field("", description="Stundennotizen")
|
||||
homework: str = Field("", description="Hausaufgaben")
|
||||
|
||||
|
||||
class ExtendTimeRequest(BaseModel):
|
||||
"""Request zum Verlaengern der aktuellen Phase (Feature f28)."""
|
||||
minutes: int = Field(5, ge=1, le=30, description="Zusaetzliche Minuten (1-30)")
|
||||
|
||||
|
||||
class PhaseInfo(BaseModel):
|
||||
"""Informationen zu einer Phase."""
|
||||
phase: str
|
||||
display_name: str
|
||||
icon: str
|
||||
duration_minutes: int
|
||||
is_completed: bool
|
||||
is_current: bool
|
||||
is_future: bool
|
||||
|
||||
|
||||
class TimerStatus(BaseModel):
|
||||
"""Timer-Status einer Phase."""
|
||||
remaining_seconds: int
|
||||
remaining_formatted: str
|
||||
total_seconds: int
|
||||
total_formatted: str
|
||||
elapsed_seconds: int
|
||||
elapsed_formatted: str
|
||||
percentage_remaining: int
|
||||
percentage_elapsed: int
|
||||
percentage: int = Field(description="Alias fuer percentage_remaining (Visual Timer)")
|
||||
warning: bool
|
||||
overtime: bool
|
||||
overtime_seconds: int
|
||||
overtime_formatted: Optional[str]
|
||||
is_paused: bool = Field(False, description="Ist der Timer pausiert?")
|
||||
|
||||
|
||||
class SuggestionItem(BaseModel):
|
||||
"""Ein Aktivitaets-Vorschlag."""
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
activity_type: str
|
||||
estimated_minutes: int
|
||||
icon: str
|
||||
content_url: Optional[str]
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Vollstaendige Session-Response."""
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
topic: Optional[str]
|
||||
current_phase: str
|
||||
phase_display_name: str
|
||||
phase_started_at: Optional[str]
|
||||
lesson_started_at: Optional[str]
|
||||
lesson_ended_at: Optional[str]
|
||||
timer: TimerStatus
|
||||
phases: List[PhaseInfo]
|
||||
phase_history: List[Dict[str, Any]]
|
||||
notes: str
|
||||
homework: str
|
||||
is_active: bool
|
||||
is_ended: bool
|
||||
is_paused: bool = Field(False, description="Ist die Stunde pausiert?")
|
||||
|
||||
|
||||
class SuggestionsResponse(BaseModel):
|
||||
"""Response fuer Vorschlaege."""
|
||||
suggestions: List[SuggestionItem]
|
||||
current_phase: str
|
||||
phase_display_name: str
|
||||
total_available: int
|
||||
|
||||
|
||||
class PhasesListResponse(BaseModel):
|
||||
"""Liste aller verfuegbaren Phasen."""
|
||||
phases: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class ActiveSessionsResponse(BaseModel):
|
||||
"""Liste aktiver Sessions."""
|
||||
sessions: List[Dict[str, Any]]
|
||||
count: int
|
||||
|
||||
|
||||
# === Session History Models ===
|
||||
|
||||
class SessionHistoryItem(BaseModel):
|
||||
"""Einzelner Eintrag in der Session-History."""
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
topic: Optional[str]
|
||||
lesson_started_at: Optional[str]
|
||||
lesson_ended_at: Optional[str]
|
||||
total_duration_minutes: Optional[int]
|
||||
phases_completed: int
|
||||
notes: str
|
||||
homework: str
|
||||
|
||||
|
||||
class SessionHistoryResponse(BaseModel):
|
||||
"""Response fuer Session-History."""
|
||||
sessions: List[SessionHistoryItem]
|
||||
total_count: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# === Template Models ===
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
"""Request zum Erstellen einer Vorlage."""
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Name der Vorlage")
|
||||
description: str = Field("", max_length=1000, description="Beschreibung")
|
||||
subject: str = Field("", max_length=100, description="Fach")
|
||||
grade_level: str = Field("", max_length=50, description="Klassenstufe (z.B. '7', '10')")
|
||||
phase_durations: Optional[Dict[str, int]] = Field(
|
||||
None,
|
||||
description="Phasendauern in Minuten"
|
||||
)
|
||||
default_topic: str = Field("", max_length=500, description="Vorausgefuelltes Thema")
|
||||
default_notes: str = Field("", description="Vorausgefuellte Notizen")
|
||||
is_public: bool = Field(False, description="Vorlage fuer alle sichtbar?")
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
"""Request zum Aktualisieren einer Vorlage."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=1000)
|
||||
subject: Optional[str] = Field(None, max_length=100)
|
||||
grade_level: Optional[str] = Field(None, max_length=50)
|
||||
phase_durations: Optional[Dict[str, int]] = None
|
||||
default_topic: Optional[str] = Field(None, max_length=500)
|
||||
default_notes: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""Response fuer eine einzelne Vorlage."""
|
||||
template_id: str
|
||||
teacher_id: str
|
||||
name: str
|
||||
description: str
|
||||
subject: str
|
||||
grade_level: str
|
||||
phase_durations: Dict[str, int]
|
||||
default_topic: str
|
||||
default_notes: str
|
||||
is_public: bool
|
||||
usage_count: int
|
||||
total_duration_minutes: int
|
||||
created_at: Optional[str]
|
||||
updated_at: Optional[str]
|
||||
is_system_template: bool = False
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
"""Response fuer Template-Liste."""
|
||||
templates: List[TemplateResponse]
|
||||
total_count: int
|
||||
|
||||
|
||||
# === Homework Models ===
|
||||
|
||||
class CreateHomeworkRequest(BaseModel):
|
||||
"""Request zum Erstellen einer Hausaufgabe."""
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
title: str = Field(..., max_length=300)
|
||||
description: str = ""
|
||||
session_id: Optional[str] = None
|
||||
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
|
||||
|
||||
|
||||
class UpdateHomeworkRequest(BaseModel):
|
||||
"""Request zum Aktualisieren einer Hausaufgabe."""
|
||||
title: Optional[str] = Field(None, max_length=300)
|
||||
description: Optional[str] = None
|
||||
due_date: Optional[str] = Field(None, description="ISO-Format Datum")
|
||||
status: Optional[str] = Field(None, description="assigned, in_progress, completed")
|
||||
|
||||
|
||||
class HomeworkResponse(BaseModel):
|
||||
"""Response fuer eine Hausaufgabe."""
|
||||
homework_id: str
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
title: str
|
||||
description: str
|
||||
session_id: Optional[str]
|
||||
due_date: Optional[str]
|
||||
status: str
|
||||
is_overdue: bool
|
||||
created_at: Optional[str]
|
||||
updated_at: Optional[str]
|
||||
|
||||
|
||||
class HomeworkListResponse(BaseModel):
|
||||
"""Response fuer Liste von Hausaufgaben."""
|
||||
homework: List[HomeworkResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# === Material Models ===
|
||||
|
||||
class CreateMaterialRequest(BaseModel):
|
||||
"""Request zum Erstellen eines Materials."""
|
||||
teacher_id: str
|
||||
title: str = Field(..., max_length=300)
|
||||
material_type: str = Field("document", description="document, link, video, image, worksheet, presentation, other")
|
||||
url: Optional[str] = Field(None, max_length=2000)
|
||||
description: str = ""
|
||||
phase: Optional[str] = Field(None, description="einstieg, erarbeitung, sicherung, transfer, reflexion")
|
||||
subject: str = ""
|
||||
grade_level: str = ""
|
||||
tags: List[str] = []
|
||||
is_public: bool = False
|
||||
session_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateMaterialRequest(BaseModel):
|
||||
"""Request zum Aktualisieren eines Materials."""
|
||||
title: Optional[str] = Field(None, max_length=300)
|
||||
material_type: Optional[str] = None
|
||||
url: Optional[str] = Field(None, max_length=2000)
|
||||
description: Optional[str] = None
|
||||
phase: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
grade_level: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
class MaterialResponse(BaseModel):
|
||||
"""Response fuer ein Material."""
|
||||
material_id: str
|
||||
teacher_id: str
|
||||
title: str
|
||||
material_type: str
|
||||
url: Optional[str]
|
||||
description: str
|
||||
phase: Optional[str]
|
||||
subject: str
|
||||
grade_level: str
|
||||
tags: List[str]
|
||||
is_public: bool
|
||||
usage_count: int
|
||||
session_id: Optional[str]
|
||||
created_at: Optional[str]
|
||||
updated_at: Optional[str]
|
||||
|
||||
|
||||
class MaterialListResponse(BaseModel):
|
||||
"""Response fuer Liste von Materialien."""
|
||||
materials: List[MaterialResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# === Analytics Models ===
|
||||
|
||||
class SessionSummaryResponse(BaseModel):
|
||||
"""Response fuer Session-Summary."""
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
class_id: str
|
||||
subject: str
|
||||
topic: Optional[str]
|
||||
date: Optional[str]
|
||||
date_formatted: str
|
||||
total_duration_seconds: int
|
||||
total_duration_formatted: str
|
||||
planned_duration_seconds: int
|
||||
planned_duration_formatted: str
|
||||
phases_completed: int
|
||||
total_phases: int
|
||||
completion_percentage: int
|
||||
phase_statistics: List[Dict[str, Any]]
|
||||
total_overtime_seconds: int
|
||||
total_overtime_formatted: str
|
||||
phases_with_overtime: int
|
||||
total_pause_count: int
|
||||
total_pause_seconds: int
|
||||
reflection_notes: str = ""
|
||||
reflection_rating: Optional[int] = None
|
||||
key_learnings: List[str] = []
|
||||
|
||||
|
||||
class TeacherAnalyticsResponse(BaseModel):
|
||||
"""Response fuer Lehrer-Analytics."""
|
||||
teacher_id: str
|
||||
period_start: Optional[str]
|
||||
period_end: Optional[str]
|
||||
total_sessions: int
|
||||
completed_sessions: int
|
||||
total_teaching_minutes: int
|
||||
total_teaching_hours: float
|
||||
avg_phase_durations: Dict[str, int]
|
||||
sessions_with_overtime: int
|
||||
overtime_percentage: int
|
||||
avg_overtime_seconds: int
|
||||
avg_overtime_formatted: str
|
||||
most_overtime_phase: Optional[str]
|
||||
avg_pause_count: float
|
||||
avg_pause_duration_seconds: int
|
||||
subjects_taught: Dict[str, int]
|
||||
classes_taught: Dict[str, int]
|
||||
|
||||
|
||||
class ReflectionCreate(BaseModel):
|
||||
"""Request-Body fuer Reflection-Erstellung."""
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
notes: str = ""
|
||||
overall_rating: Optional[int] = Field(None, ge=1, le=5)
|
||||
what_worked: List[str] = []
|
||||
improvements: List[str] = []
|
||||
notes_for_next_lesson: str = ""
|
||||
|
||||
|
||||
class ReflectionUpdate(BaseModel):
|
||||
"""Request-Body fuer Reflection-Update."""
|
||||
notes: Optional[str] = None
|
||||
overall_rating: Optional[int] = Field(None, ge=1, le=5)
|
||||
what_worked: Optional[List[str]] = None
|
||||
improvements: Optional[List[str]] = None
|
||||
notes_for_next_lesson: Optional[str] = None
|
||||
|
||||
|
||||
class ReflectionResponse(BaseModel):
|
||||
"""Response fuer eine einzelne Reflection."""
|
||||
reflection_id: str
|
||||
session_id: str
|
||||
teacher_id: str
|
||||
notes: str
|
||||
overall_rating: Optional[int]
|
||||
what_worked: List[str]
|
||||
improvements: List[str]
|
||||
notes_for_next_lesson: str
|
||||
created_at: Optional[str]
|
||||
updated_at: Optional[str]
|
||||
|
||||
|
||||
# === Feedback Models ===
|
||||
|
||||
class FeedbackCreate(BaseModel):
|
||||
"""Request zum Erstellen von Feedback."""
|
||||
title: str = Field(..., min_length=3, max_length=500, description="Kurzer Titel")
|
||||
description: str = Field(..., min_length=10, description="Beschreibung")
|
||||
feedback_type: str = Field("improvement", description="bug, feature_request, improvement, praise, question")
|
||||
priority: str = Field("medium", description="critical, high, medium, low")
|
||||
teacher_name: str = Field("", description="Name des Lehrers")
|
||||
teacher_email: str = Field("", description="E-Mail fuer Rueckfragen")
|
||||
context_url: str = Field("", description="URL wo Feedback gegeben wurde")
|
||||
context_phase: str = Field("", description="Aktuelle Phase")
|
||||
context_session_id: Optional[str] = Field(None, description="Session-ID falls aktiv")
|
||||
related_feature: Optional[str] = Field(None, description="Verwandtes Feature")
|
||||
|
||||
|
||||
class FeedbackResponse(BaseModel):
|
||||
"""Response fuer Feedback."""
|
||||
id: str
|
||||
teacher_id: str
|
||||
teacher_name: str
|
||||
title: str
|
||||
description: str
|
||||
feedback_type: str
|
||||
priority: str
|
||||
status: str
|
||||
created_at: str
|
||||
response: Optional[str] = None
|
||||
|
||||
|
||||
class FeedbackListResponse(BaseModel):
|
||||
"""Liste von Feedbacks."""
|
||||
feedbacks: List[Dict[str, Any]]
|
||||
total: int
|
||||
|
||||
|
||||
class FeedbackStatsResponse(BaseModel):
|
||||
"""Feedback-Statistiken."""
|
||||
total: int
|
||||
by_status: Dict[str, int]
|
||||
by_type: Dict[str, int]
|
||||
by_priority: Dict[str, int]
|
||||
|
||||
|
||||
# === Settings Models ===
|
||||
|
||||
class TeacherSettingsResponse(BaseModel):
|
||||
"""Response fuer Lehrer-Einstellungen."""
|
||||
teacher_id: str
|
||||
default_phase_durations: Dict[str, int]
|
||||
audio_enabled: bool = True
|
||||
high_contrast: bool = False
|
||||
show_statistics: bool = True
|
||||
|
||||
|
||||
class UpdatePhaseDurationsRequest(BaseModel):
|
||||
"""Request zum Aktualisieren der Phasen-Dauern."""
|
||||
durations: Dict[str, int] = Field(
|
||||
...,
|
||||
description="Phasen-Dauern in Minuten, z.B. {'einstieg': 10, 'erarbeitung': 25}",
|
||||
examples=[{"einstieg": 10, "erarbeitung": 25, "sicherung": 10, "transfer": 8, "reflexion": 5}]
|
||||
)
|
||||
|
||||
|
||||
class UpdatePreferencesRequest(BaseModel):
|
||||
"""Request zum Aktualisieren der UI-Praeferenzen."""
|
||||
audio_enabled: Optional[bool] = None
|
||||
high_contrast: Optional[bool] = None
|
||||
show_statistics: Optional[bool] = None
|
||||
|
||||
|
||||
# === Context Models ===
|
||||
|
||||
class SchoolInfo(BaseModel):
|
||||
"""Schul-Informationen."""
|
||||
federal_state: str
|
||||
federal_state_name: str = ""
|
||||
school_type: str
|
||||
school_type_name: str = ""
|
||||
|
||||
|
||||
class SchoolYearInfo(BaseModel):
|
||||
"""Schuljahr-Informationen."""
|
||||
id: str
|
||||
start: Optional[str] = None
|
||||
current_week: int = 1
|
||||
|
||||
|
||||
class MacroPhaseInfo(BaseModel):
|
||||
"""Makro-Phase Informationen."""
|
||||
id: str
|
||||
label: str
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
class CoreCounts(BaseModel):
|
||||
"""Kern-Zaehler fuer den Kontext."""
|
||||
classes: int = 0
|
||||
exams_scheduled: int = 0
|
||||
corrections_pending: int = 0
|
||||
|
||||
|
||||
class ContextFlags(BaseModel):
|
||||
"""Status-Flags des Kontexts."""
|
||||
onboarding_completed: bool = False
|
||||
has_classes: bool = False
|
||||
has_schedule: bool = False
|
||||
is_exam_period: bool = False
|
||||
is_before_holidays: bool = False
|
||||
|
||||
|
||||
class TeacherContextResponse(BaseModel):
|
||||
"""Response fuer GET /v1/context."""
|
||||
schema_version: str = "1.0"
|
||||
teacher_id: str
|
||||
school: SchoolInfo
|
||||
school_year: SchoolYearInfo
|
||||
macro_phase: MacroPhaseInfo
|
||||
core_counts: CoreCounts
|
||||
flags: ContextFlags
|
||||
|
||||
|
||||
class UpdateContextRequest(BaseModel):
|
||||
"""Request zum Aktualisieren des Kontexts."""
|
||||
federal_state: Optional[str] = None
|
||||
school_type: Optional[str] = None
|
||||
schoolyear: Optional[str] = None
|
||||
schoolyear_start: Optional[str] = None
|
||||
macro_phase: Optional[str] = None
|
||||
current_week: Optional[int] = None
|
||||
|
||||
|
||||
# === Event Models ===
|
||||
|
||||
class CreateEventRequest(BaseModel):
|
||||
"""Request zum Erstellen eines Events."""
|
||||
title: str
|
||||
event_type: str = "other"
|
||||
start_date: str
|
||||
end_date: Optional[str] = None
|
||||
class_id: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
description: str = ""
|
||||
needs_preparation: bool = True
|
||||
reminder_days_before: int = 7
|
||||
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
"""Response fuer ein Event."""
|
||||
id: str
|
||||
teacher_id: str
|
||||
event_type: str
|
||||
title: str
|
||||
description: str
|
||||
start_date: str
|
||||
end_date: Optional[str]
|
||||
class_id: Optional[str]
|
||||
subject: Optional[str]
|
||||
status: str
|
||||
needs_preparation: bool
|
||||
preparation_done: bool
|
||||
reminder_days_before: int
|
||||
|
||||
|
||||
# === Routine Models ===
|
||||
|
||||
class CreateRoutineRequest(BaseModel):
|
||||
"""Request zum Erstellen einer Routine."""
|
||||
title: str
|
||||
routine_type: str = "other"
|
||||
recurrence_pattern: str = "weekly"
|
||||
day_of_week: Optional[int] = None
|
||||
day_of_month: Optional[int] = None
|
||||
time_of_day: Optional[str] = None
|
||||
duration_minutes: int = 60
|
||||
description: str = ""
|
||||
|
||||
|
||||
class RoutineResponse(BaseModel):
|
||||
"""Response fuer eine Routine."""
|
||||
id: str
|
||||
teacher_id: str
|
||||
routine_type: str
|
||||
title: str
|
||||
description: str
|
||||
recurrence_pattern: str
|
||||
day_of_week: Optional[int]
|
||||
day_of_month: Optional[int]
|
||||
time_of_day: Optional[str]
|
||||
duration_minutes: int
|
||||
is_active: bool
|
||||
29
backend/classroom/routes/__init__.py
Normal file
29
backend/classroom/routes/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Classroom Routes Package
|
||||
|
||||
Exports all route modules for the Classroom API.
|
||||
"""
|
||||
|
||||
from .sessions import router as sessions_router
|
||||
from .templates import router as templates_router
|
||||
from .homework import router as homework_router
|
||||
from .materials import router as materials_router
|
||||
from .analytics import router as analytics_router
|
||||
from .export import router as export_router
|
||||
from .feedback import router as feedback_router
|
||||
from .settings import router as settings_router
|
||||
from .context import router as context_router
|
||||
from .websocket_routes import router as websocket_router
|
||||
|
||||
__all__ = [
|
||||
"sessions_router",
|
||||
"templates_router",
|
||||
"homework_router",
|
||||
"materials_router",
|
||||
"analytics_router",
|
||||
"export_router",
|
||||
"feedback_router",
|
||||
"settings_router",
|
||||
"context_router",
|
||||
"websocket_router",
|
||||
]
|
||||
369
backend/classroom/routes/analytics.py
Normal file
369
backend/classroom/routes/analytics.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Classroom API - Analytics Routes
|
||||
|
||||
Analytics and reflection endpoints (Phase 5).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from classroom_engine import LessonReflection
|
||||
|
||||
from ..models import (
|
||||
SessionSummaryResponse,
|
||||
TeacherAnalyticsResponse,
|
||||
ReflectionCreate,
|
||||
ReflectionUpdate,
|
||||
ReflectionResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Analytics"])
|
||||
|
||||
|
||||
# === Analytics Endpoints ===
|
||||
|
||||
@router.get("/analytics/session/{session_id}")
|
||||
async def get_session_summary(session_id: str) -> SessionSummaryResponse:
|
||||
"""
|
||||
Gibt die Analytics-Zusammenfassung einer Session zurueck.
|
||||
|
||||
Berechnet Phasen-Dauer Statistiken, Overtime und Pausen-Analyse.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import AnalyticsRepository
|
||||
repo = AnalyticsRepository(db)
|
||||
summary = repo.get_session_summary(session_id)
|
||||
|
||||
if not summary:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
|
||||
return SessionSummaryResponse(**summary.to_dict())
|
||||
|
||||
|
||||
@router.get("/analytics/teacher/{teacher_id}")
|
||||
async def get_teacher_analytics(
|
||||
teacher_id: str,
|
||||
days: int = Query(30, ge=1, le=365)
|
||||
) -> TeacherAnalyticsResponse:
|
||||
"""
|
||||
Gibt aggregierte Analytics fuer einen Lehrer zurueck.
|
||||
|
||||
Berechnet Trends ueber den angegebenen Zeitraum.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
period_end = datetime.utcnow()
|
||||
period_start = period_end - timedelta(days=days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import AnalyticsRepository
|
||||
repo = AnalyticsRepository(db)
|
||||
analytics = repo.get_teacher_analytics(
|
||||
teacher_id, period_start, period_end
|
||||
)
|
||||
|
||||
return TeacherAnalyticsResponse(**analytics.to_dict())
|
||||
|
||||
|
||||
@router.get("/analytics/phase-trends/{teacher_id}/{phase}")
|
||||
async def get_phase_trends(
|
||||
teacher_id: str,
|
||||
phase: str,
|
||||
limit: int = Query(20, ge=1, le=100)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Gibt die Dauer-Trends fuer eine Phase zurueck.
|
||||
|
||||
Nuetzlich fuer Charts und Visualisierungen.
|
||||
"""
|
||||
if phase not in ["einstieg", "erarbeitung", "sicherung", "transfer", "reflexion"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid phase: {phase}"
|
||||
)
|
||||
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import AnalyticsRepository
|
||||
repo = AnalyticsRepository(db)
|
||||
trends = repo.get_phase_duration_trends(teacher_id, phase, limit)
|
||||
|
||||
return {
|
||||
"teacher_id": teacher_id,
|
||||
"phase": phase,
|
||||
"data_points": trends,
|
||||
"count": len(trends)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics/overtime/{teacher_id}")
|
||||
async def get_overtime_analysis(
|
||||
teacher_id: str,
|
||||
limit: int = Query(30, ge=1, le=100)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analysiert Overtime-Muster nach Phase.
|
||||
|
||||
Zeigt welche Phasen am haeufigsten ueberzogen werden.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import AnalyticsRepository
|
||||
repo = AnalyticsRepository(db)
|
||||
analysis = repo.get_overtime_analysis(teacher_id, limit)
|
||||
|
||||
return {
|
||||
"teacher_id": teacher_id,
|
||||
"sessions_analyzed": limit,
|
||||
"phases": analysis
|
||||
}
|
||||
|
||||
|
||||
# === Reflection Endpoints ===
|
||||
|
||||
@router.post("/reflections", status_code=201)
|
||||
async def create_reflection(data: ReflectionCreate) -> ReflectionResponse:
|
||||
"""
|
||||
Erstellt eine Post-Lesson Reflection.
|
||||
|
||||
Erlaubt Lehrern, nach der Stunde Notizen zu speichern.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import ReflectionRepository
|
||||
repo = ReflectionRepository(db)
|
||||
|
||||
# Pruefen ob schon eine Reflection existiert
|
||||
existing = repo.get_by_session(data.session_id)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Reflection for session {data.session_id} already exists"
|
||||
)
|
||||
|
||||
reflection = LessonReflection(
|
||||
reflection_id=str(uuid.uuid4()),
|
||||
session_id=data.session_id,
|
||||
teacher_id=data.teacher_id,
|
||||
notes=data.notes,
|
||||
overall_rating=data.overall_rating,
|
||||
what_worked=data.what_worked,
|
||||
improvements=data.improvements,
|
||||
notes_for_next_lesson=data.notes_for_next_lesson,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
db_reflection = repo.create(reflection)
|
||||
result = repo.to_dataclass(db_reflection)
|
||||
|
||||
return ReflectionResponse(**result.to_dict())
|
||||
|
||||
|
||||
@router.get("/reflections/session/{session_id}")
|
||||
async def get_reflection_by_session(session_id: str) -> ReflectionResponse:
|
||||
"""
|
||||
Holt die Reflection einer Session.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import ReflectionRepository
|
||||
repo = ReflectionRepository(db)
|
||||
db_reflection = repo.get_by_session(session_id)
|
||||
|
||||
if not db_reflection:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No reflection for session {session_id}"
|
||||
)
|
||||
|
||||
result = repo.to_dataclass(db_reflection)
|
||||
return ReflectionResponse(**result.to_dict())
|
||||
|
||||
|
||||
@router.get("/reflections/teacher/{teacher_id}")
|
||||
async def get_reflections_by_teacher(
|
||||
teacher_id: str,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Holt alle Reflections eines Lehrers.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import ReflectionRepository
|
||||
repo = ReflectionRepository(db)
|
||||
db_reflections = repo.get_by_teacher(teacher_id, limit, offset)
|
||||
|
||||
reflections = [
|
||||
repo.to_dataclass(r).to_dict()
|
||||
for r in db_reflections
|
||||
]
|
||||
|
||||
return {
|
||||
"teacher_id": teacher_id,
|
||||
"reflections": reflections,
|
||||
"count": len(reflections),
|
||||
"offset": offset,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
|
||||
@router.put("/reflections/{reflection_id}")
|
||||
async def update_reflection(
|
||||
reflection_id: str,
|
||||
data: ReflectionUpdate,
|
||||
teacher_id: str = Query(...)
|
||||
) -> ReflectionResponse:
|
||||
"""
|
||||
Aktualisiert eine Reflection.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import ReflectionRepository
|
||||
repo = ReflectionRepository(db)
|
||||
db_reflection = repo.get_by_id(reflection_id)
|
||||
|
||||
if not db_reflection:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Reflection {reflection_id} not found"
|
||||
)
|
||||
|
||||
if db_reflection.teacher_id != teacher_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to update this reflection"
|
||||
)
|
||||
|
||||
# Vorhandene Werte beibehalten wenn nicht im Update
|
||||
reflection = repo.to_dataclass(db_reflection)
|
||||
|
||||
if data.notes is not None:
|
||||
reflection.notes = data.notes
|
||||
if data.overall_rating is not None:
|
||||
reflection.overall_rating = data.overall_rating
|
||||
if data.what_worked is not None:
|
||||
reflection.what_worked = data.what_worked
|
||||
if data.improvements is not None:
|
||||
reflection.improvements = data.improvements
|
||||
if data.notes_for_next_lesson is not None:
|
||||
reflection.notes_for_next_lesson = data.notes_for_next_lesson
|
||||
|
||||
reflection.updated_at = datetime.utcnow()
|
||||
|
||||
db_updated = repo.update(reflection)
|
||||
result = repo.to_dataclass(db_updated)
|
||||
|
||||
return ReflectionResponse(**result.to_dict())
|
||||
|
||||
|
||||
@router.delete("/reflections/{reflection_id}")
|
||||
async def delete_reflection(
|
||||
reflection_id: str,
|
||||
teacher_id: str = Query(...)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Loescht eine Reflection.
|
||||
"""
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database not available"
|
||||
)
|
||||
|
||||
init_db_if_needed()
|
||||
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import ReflectionRepository
|
||||
repo = ReflectionRepository(db)
|
||||
db_reflection = repo.get_by_id(reflection_id)
|
||||
|
||||
if not db_reflection:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Reflection {reflection_id} not found"
|
||||
)
|
||||
|
||||
if db_reflection.teacher_id != teacher_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to delete this reflection"
|
||||
)
|
||||
|
||||
success = repo.delete(reflection_id)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"deleted_id": reflection_id
|
||||
}
|
||||
726
backend/classroom/routes/context.py
Normal file
726
backend/classroom/routes/context.py
Normal file
@@ -0,0 +1,726 @@
|
||||
"""
|
||||
Classroom API - Context Routes
|
||||
|
||||
School year context, events, routines, and suggestions endpoints (Phase 8).
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
|
||||
from classroom_engine import (
|
||||
FEDERAL_STATES,
|
||||
SCHOOL_TYPES,
|
||||
MacroPhaseEnum,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
TeacherContextResponse,
|
||||
SchoolInfo,
|
||||
SchoolYearInfo,
|
||||
MacroPhaseInfo,
|
||||
CoreCounts,
|
||||
ContextFlags,
|
||||
UpdateContextRequest,
|
||||
CreateEventRequest,
|
||||
EventResponse,
|
||||
CreateRoutineRequest,
|
||||
RoutineResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Context"])
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Database session dependency."""
|
||||
if DB_ENABLED and SessionLocal:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
yield None
|
||||
|
||||
|
||||
def _get_macro_phase_label(phase) -> str:
|
||||
"""Gibt den Anzeigenamen einer Makro-Phase zurueck."""
|
||||
labels = {
|
||||
"onboarding": "Einrichtung",
|
||||
"schuljahresstart": "Schuljahresstart",
|
||||
"unterrichtsaufbau": "Unterrichtsaufbau",
|
||||
"leistungsphase_1": "Leistungsphase 1",
|
||||
"halbjahresabschluss": "Halbjahresabschluss",
|
||||
"leistungsphase_2": "Leistungsphase 2",
|
||||
"jahresabschluss": "Jahresabschluss",
|
||||
}
|
||||
phase_value = phase.value if hasattr(phase, 'value') else str(phase)
|
||||
return labels.get(phase_value, phase_value)
|
||||
|
||||
|
||||
# === Context Endpoints ===
|
||||
|
||||
@router.get("/v1/context", response_model=TeacherContextResponse)
|
||||
async def get_teacher_context(
|
||||
teacher_id: str = Query(..., description="Teacher ID"),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Liefert den aktuellen Makro-Kontext eines Lehrers.
|
||||
|
||||
Der Kontext beinhaltet:
|
||||
- Schul-Informationen (Bundesland, Schulart)
|
||||
- Schuljahr-Daten (aktuelles Jahr, Woche)
|
||||
- Makro-Phase (ONBOARDING bis JAHRESABSCHLUSS)
|
||||
- Zaehler (Klassen, geplante Klausuren, etc.)
|
||||
- Status-Flags (Onboarding abgeschlossen, etc.)
|
||||
"""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository, SchoolyearEventRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
|
||||
# Zaehler berechnen
|
||||
event_repo = SchoolyearEventRepository(db)
|
||||
upcoming_exams = event_repo.get_upcoming(teacher_id, days=30)
|
||||
exams_count = len([e for e in upcoming_exams if e.event_type.value == "exam"])
|
||||
|
||||
return TeacherContextResponse(
|
||||
schema_version="1.0",
|
||||
teacher_id=teacher_id,
|
||||
school=SchoolInfo(
|
||||
federal_state=context.federal_state or "BY",
|
||||
federal_state_name=FEDERAL_STATES.get(context.federal_state, ""),
|
||||
school_type=context.school_type or "gymnasium",
|
||||
school_type_name=SCHOOL_TYPES.get(context.school_type, ""),
|
||||
),
|
||||
school_year=SchoolYearInfo(
|
||||
id=context.schoolyear or "2024-2025",
|
||||
start=context.schoolyear_start.isoformat() if context.schoolyear_start else None,
|
||||
current_week=context.current_week or 1,
|
||||
),
|
||||
macro_phase=MacroPhaseInfo(
|
||||
id=context.macro_phase.value,
|
||||
label=_get_macro_phase_label(context.macro_phase),
|
||||
confidence=1.0,
|
||||
),
|
||||
core_counts=CoreCounts(
|
||||
classes=1 if context.has_classes else 0,
|
||||
exams_scheduled=exams_count,
|
||||
corrections_pending=0,
|
||||
),
|
||||
flags=ContextFlags(
|
||||
onboarding_completed=context.onboarding_completed,
|
||||
has_classes=context.has_classes,
|
||||
has_schedule=context.has_schedule,
|
||||
is_exam_period=context.is_exam_period,
|
||||
is_before_holidays=context.is_before_holidays,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get teacher context: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Laden des Kontexts: {e}")
|
||||
|
||||
# Fallback ohne DB
|
||||
return TeacherContextResponse(
|
||||
schema_version="1.0",
|
||||
teacher_id=teacher_id,
|
||||
school=SchoolInfo(
|
||||
federal_state="BY",
|
||||
federal_state_name="Bayern",
|
||||
school_type="gymnasium",
|
||||
school_type_name="Gymnasium",
|
||||
),
|
||||
school_year=SchoolYearInfo(
|
||||
id="2024-2025",
|
||||
start=None,
|
||||
current_week=1,
|
||||
),
|
||||
macro_phase=MacroPhaseInfo(
|
||||
id="onboarding",
|
||||
label="Einrichtung",
|
||||
confidence=1.0,
|
||||
),
|
||||
core_counts=CoreCounts(),
|
||||
flags=ContextFlags(),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/v1/context", response_model=TeacherContextResponse)
|
||||
async def update_teacher_context(
|
||||
teacher_id: str,
|
||||
request: UpdateContextRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Aktualisiert den Kontext eines Lehrers.
|
||||
"""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
|
||||
# Validierung
|
||||
if request.federal_state and request.federal_state not in FEDERAL_STATES:
|
||||
raise HTTPException(status_code=400, detail=f"Ungueltiges Bundesland: {request.federal_state}")
|
||||
if request.school_type and request.school_type not in SCHOOL_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Ungueltige Schulart: {request.school_type}")
|
||||
|
||||
# Parse datetime if provided
|
||||
schoolyear_start = None
|
||||
if request.schoolyear_start:
|
||||
schoolyear_start = datetime.fromisoformat(request.schoolyear_start.replace('Z', '+00:00'))
|
||||
|
||||
repo.update_context(
|
||||
teacher_id=teacher_id,
|
||||
federal_state=request.federal_state,
|
||||
school_type=request.school_type,
|
||||
schoolyear=request.schoolyear,
|
||||
schoolyear_start=schoolyear_start,
|
||||
macro_phase=request.macro_phase,
|
||||
current_week=request.current_week,
|
||||
)
|
||||
|
||||
return await get_teacher_context(teacher_id, db)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update teacher context: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Aktualisieren: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/context/complete-onboarding")
|
||||
async def complete_onboarding(
|
||||
teacher_id: str = Query(...),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Markiert das Onboarding als abgeschlossen."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"success": True, "macro_phase": "schuljahresstart", "note": "DB not available"}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.complete_onboarding(teacher_id)
|
||||
return {
|
||||
"success": True,
|
||||
"macro_phase": context.macro_phase.value,
|
||||
"teacher_id": teacher_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to complete onboarding: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/context/reset-onboarding")
|
||||
async def reset_onboarding(
|
||||
teacher_id: str = Query(...),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Setzt das Onboarding zurueck (fuer Tests)."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"success": True, "macro_phase": "onboarding", "note": "DB not available"}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
context.onboarding_completed = False
|
||||
context.macro_phase = MacroPhaseEnum.ONBOARDING
|
||||
db.commit()
|
||||
db.refresh(context)
|
||||
return {
|
||||
"success": True,
|
||||
"macro_phase": "onboarding",
|
||||
"teacher_id": teacher_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset onboarding: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
# === Events Endpoints ===
|
||||
|
||||
@router.get("/v1/events")
|
||||
async def get_events(
|
||||
teacher_id: str = Query(...),
|
||||
status: Optional[str] = None,
|
||||
event_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt Events eines Lehrers."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"events": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
events = repo.get_by_teacher(teacher_id, status=status, event_type=event_type, limit=limit)
|
||||
return {
|
||||
"events": [repo.to_dict(e) for e in events],
|
||||
"count": len(events),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get events: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.get("/v1/events/upcoming")
|
||||
async def get_upcoming_events(
|
||||
teacher_id: str = Query(...),
|
||||
days: int = 30,
|
||||
limit: int = 10,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt anstehende Events der naechsten X Tage."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"events": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
events = repo.get_upcoming(teacher_id, days=days, limit=limit)
|
||||
return {
|
||||
"events": [repo.to_dict(e) for e in events],
|
||||
"count": len(events),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get upcoming events: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/events", response_model=EventResponse)
|
||||
async def create_event(
|
||||
teacher_id: str,
|
||||
request: CreateEventRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Erstellt ein neues Schuljahr-Event."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
|
||||
end_date = None
|
||||
if request.end_date:
|
||||
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00'))
|
||||
|
||||
event = repo.create(
|
||||
teacher_id=teacher_id,
|
||||
title=request.title,
|
||||
event_type=request.event_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
class_id=request.class_id,
|
||||
subject=request.subject,
|
||||
description=request.description,
|
||||
needs_preparation=request.needs_preparation,
|
||||
reminder_days_before=request.reminder_days_before,
|
||||
)
|
||||
|
||||
return EventResponse(
|
||||
id=event.id,
|
||||
teacher_id=event.teacher_id,
|
||||
event_type=event.event_type.value,
|
||||
title=event.title,
|
||||
description=event.description,
|
||||
start_date=event.start_date.isoformat(),
|
||||
end_date=event.end_date.isoformat() if event.end_date else None,
|
||||
class_id=event.class_id,
|
||||
subject=event.subject,
|
||||
status=event.status.value,
|
||||
needs_preparation=event.needs_preparation,
|
||||
preparation_done=event.preparation_done,
|
||||
reminder_days_before=event.reminder_days_before,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create event: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.delete("/v1/events/{event_id}")
|
||||
async def delete_event(event_id: str, db=Depends(get_db)):
|
||||
"""Loescht ein Event."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import SchoolyearEventRepository
|
||||
repo = SchoolyearEventRepository(db)
|
||||
if repo.delete(event_id):
|
||||
return {"success": True, "deleted_id": event_id}
|
||||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete event: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
# === Routines Endpoints ===
|
||||
|
||||
@router.get("/v1/routines")
|
||||
async def get_routines(
|
||||
teacher_id: str = Query(...),
|
||||
is_active: bool = True,
|
||||
routine_type: Optional[str] = None,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Holt Routinen eines Lehrers."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"routines": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routines = repo.get_by_teacher(teacher_id, is_active=is_active, routine_type=routine_type)
|
||||
return {
|
||||
"routines": [repo.to_dict(r) for r in routines],
|
||||
"count": len(routines),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get routines: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.get("/v1/routines/today")
|
||||
async def get_today_routines(teacher_id: str = Query(...), db=Depends(get_db)):
|
||||
"""Holt Routinen die heute stattfinden."""
|
||||
if not DB_ENABLED or not db:
|
||||
return {"routines": [], "count": 0}
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routines = repo.get_today(teacher_id)
|
||||
return {
|
||||
"routines": [repo.to_dict(r) for r in routines],
|
||||
"count": len(routines),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get today's routines: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.post("/v1/routines", response_model=RoutineResponse)
|
||||
async def create_routine(
|
||||
teacher_id: str,
|
||||
request: CreateRoutineRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Erstellt eine neue wiederkehrende Routine."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
routine = repo.create(
|
||||
teacher_id=teacher_id,
|
||||
title=request.title,
|
||||
routine_type=request.routine_type,
|
||||
recurrence_pattern=request.recurrence_pattern,
|
||||
day_of_week=request.day_of_week,
|
||||
day_of_month=request.day_of_month,
|
||||
time_of_day=request.time_of_day,
|
||||
duration_minutes=request.duration_minutes,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
return RoutineResponse(
|
||||
id=routine.id,
|
||||
teacher_id=routine.teacher_id,
|
||||
routine_type=routine.routine_type.value,
|
||||
title=routine.title,
|
||||
description=routine.description,
|
||||
recurrence_pattern=routine.recurrence_pattern.value,
|
||||
day_of_week=routine.day_of_week,
|
||||
day_of_month=routine.day_of_month,
|
||||
time_of_day=routine.time_of_day.isoformat() if routine.time_of_day else None,
|
||||
duration_minutes=routine.duration_minutes,
|
||||
is_active=routine.is_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create routine: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
@router.delete("/v1/routines/{routine_id}")
|
||||
async def delete_routine(routine_id: str, db=Depends(get_db)):
|
||||
"""Loescht eine Routine."""
|
||||
if not DB_ENABLED or not db:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import RecurringRoutineRepository
|
||||
repo = RecurringRoutineRepository(db)
|
||||
if repo.delete(routine_id):
|
||||
return {"success": True, "deleted_id": routine_id}
|
||||
raise HTTPException(status_code=404, detail="Routine nicht gefunden")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete routine: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
|
||||
# === Static Data Endpoints ===
|
||||
|
||||
@router.get("/v1/federal-states")
|
||||
async def get_federal_states():
|
||||
"""Gibt alle Bundeslaender zurueck."""
|
||||
return {
|
||||
"federal_states": [{"id": k, "name": v} for k, v in FEDERAL_STATES.items()]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/school-types")
|
||||
async def get_school_types():
|
||||
"""Gibt alle Schularten zurueck."""
|
||||
return {
|
||||
"school_types": [{"id": k, "name": v} for k, v in SCHOOL_TYPES.items()]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/macro-phases")
|
||||
async def get_macro_phases():
|
||||
"""Gibt alle Makro-Phasen mit Beschreibungen zurueck."""
|
||||
phases = [
|
||||
{"id": "onboarding", "label": "Einrichtung", "description": "Ersteinrichtung (Klassen, Stundenplan)", "order": 1},
|
||||
{"id": "schuljahresstart", "label": "Schuljahresstart", "description": "Erste 2-3 Wochen des Schuljahres", "order": 2},
|
||||
{"id": "unterrichtsaufbau", "label": "Unterrichtsaufbau", "description": "Routinen etablieren, erste Bewertungen", "order": 3},
|
||||
{"id": "leistungsphase_1", "label": "Leistungsphase 1", "description": "Erste Klassenarbeiten und Klausuren", "order": 4},
|
||||
{"id": "halbjahresabschluss", "label": "Halbjahresabschluss", "description": "Notenschluss, Zeugnisse, Konferenzen", "order": 5},
|
||||
{"id": "leistungsphase_2", "label": "Leistungsphase 2", "description": "Zweites Halbjahr, Pruefungsvorbereitung", "order": 6},
|
||||
{"id": "jahresabschluss", "label": "Jahresabschluss", "description": "Finale Noten, Versetzung, Schuljahresende", "order": 7},
|
||||
]
|
||||
return {"macro_phases": phases}
|
||||
|
||||
|
||||
@router.get("/v1/event-types")
|
||||
async def get_event_types():
|
||||
"""Gibt alle Event-Typen zurueck."""
|
||||
types = [
|
||||
{"id": "exam", "label": "Klassenarbeit/Klausur"},
|
||||
{"id": "parent_evening", "label": "Elternabend"},
|
||||
{"id": "trip", "label": "Klassenfahrt/Ausflug"},
|
||||
{"id": "project", "label": "Projektwoche"},
|
||||
{"id": "internship", "label": "Praktikum"},
|
||||
{"id": "presentation", "label": "Referate/Praesentationen"},
|
||||
{"id": "sports_day", "label": "Sporttag"},
|
||||
{"id": "school_festival", "label": "Schulfest"},
|
||||
{"id": "parent_consultation", "label": "Elternsprechtag"},
|
||||
{"id": "grade_deadline", "label": "Notenschluss"},
|
||||
{"id": "report_cards", "label": "Zeugnisausgabe"},
|
||||
{"id": "holiday_start", "label": "Ferienbeginn"},
|
||||
{"id": "holiday_end", "label": "Ferienende"},
|
||||
{"id": "other", "label": "Sonstiges"},
|
||||
]
|
||||
return {"event_types": types}
|
||||
|
||||
|
||||
@router.get("/v1/routine-types")
|
||||
async def get_routine_types():
|
||||
"""Gibt alle Routine-Typen zurueck."""
|
||||
types = [
|
||||
{"id": "teacher_conference", "label": "Lehrerkonferenz"},
|
||||
{"id": "subject_conference", "label": "Fachkonferenz"},
|
||||
{"id": "office_hours", "label": "Sprechstunde"},
|
||||
{"id": "team_meeting", "label": "Teamsitzung"},
|
||||
{"id": "supervision", "label": "Pausenaufsicht"},
|
||||
{"id": "correction_time", "label": "Korrekturzeit"},
|
||||
{"id": "prep_time", "label": "Vorbereitungszeit"},
|
||||
{"id": "other", "label": "Sonstiges"},
|
||||
]
|
||||
return {"routine_types": types}
|
||||
|
||||
|
||||
# === Suggestions & Sidebar ===
|
||||
|
||||
@router.get("/v1/suggestions")
|
||||
async def get_suggestions(
|
||||
teacher_id: str = Query(...),
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Generiert kontextbasierte Vorschlaege fuer einen Lehrer."""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.suggestions import SuggestionGenerator
|
||||
generator = SuggestionGenerator(db)
|
||||
result = generator.generate(teacher_id, limit=limit)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate suggestions: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler: {e}")
|
||||
|
||||
return {
|
||||
"active_contexts": [],
|
||||
"suggestions": [],
|
||||
"signals_summary": {
|
||||
"macro_phase": "onboarding",
|
||||
"current_week": 1,
|
||||
"has_classes": False,
|
||||
"exams_soon": 0,
|
||||
"routines_today": 0,
|
||||
},
|
||||
"total_suggestions": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/sidebar")
|
||||
async def get_sidebar(
|
||||
teacher_id: str = Query(...),
|
||||
mode: str = Query("companion"),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Generiert das dynamische Sidebar-Model."""
|
||||
if mode == "companion":
|
||||
now_relevant = []
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.suggestions import SuggestionGenerator
|
||||
generator = SuggestionGenerator(db)
|
||||
result = generator.generate(teacher_id, limit=5)
|
||||
now_relevant = [
|
||||
{
|
||||
"id": s["id"],
|
||||
"label": s["title"],
|
||||
"state": "recommended" if s["priority"] > 70 else "default",
|
||||
"badge": s.get("badge"),
|
||||
"icon": s.get("icon", "lightbulb"),
|
||||
"action_url": s.get("action_url"),
|
||||
}
|
||||
for s in result.get("suggestions", [])
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get suggestions for sidebar: {e}")
|
||||
|
||||
return {
|
||||
"mode": "companion",
|
||||
"sections": [
|
||||
{"id": "SEARCH", "type": "search_bar", "placeholder": "Suchen..."},
|
||||
{
|
||||
"id": "NOW_RELEVANT",
|
||||
"type": "list",
|
||||
"title": "Jetzt relevant",
|
||||
"items": now_relevant if now_relevant else [
|
||||
{"id": "no_suggestions", "label": "Keine Vorschlaege", "state": "default", "icon": "check_circle"}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "ALL_MODULES",
|
||||
"type": "folder",
|
||||
"label": "Alle Module",
|
||||
"icon": "folder",
|
||||
"collapsed": True,
|
||||
"items": [
|
||||
{"id": "lesson", "label": "Stundenmodus", "icon": "timer"},
|
||||
{"id": "classes", "label": "Klassen", "icon": "groups"},
|
||||
{"id": "exams", "label": "Klausuren", "icon": "quiz"},
|
||||
{"id": "grades", "label": "Noten", "icon": "calculate"},
|
||||
{"id": "calendar", "label": "Kalender", "icon": "calendar_month"},
|
||||
{"id": "materials", "label": "Materialien", "icon": "folder_open"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "QUICK_ACTIONS",
|
||||
"type": "actions",
|
||||
"title": "Kurzaktionen",
|
||||
"items": [
|
||||
{"id": "scan", "label": "Scan hochladen", "icon": "upload_file"},
|
||||
{"id": "note", "label": "Notiz erstellen", "icon": "note_add"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mode": "classic",
|
||||
"sections": [
|
||||
{
|
||||
"id": "NAVIGATION",
|
||||
"type": "tree",
|
||||
"items": [
|
||||
{"id": "dashboard", "label": "Dashboard", "icon": "dashboard", "url": "/dashboard"},
|
||||
{"id": "lesson", "label": "Stundenmodus", "icon": "timer", "url": "/lesson"},
|
||||
{"id": "classes", "label": "Klassen", "icon": "groups", "url": "/classes"},
|
||||
{"id": "exams", "label": "Klausuren", "icon": "quiz", "url": "/exams"},
|
||||
{"id": "grades", "label": "Noten", "icon": "calculate", "url": "/grades"},
|
||||
{"id": "calendar", "label": "Kalender", "icon": "calendar_month", "url": "/calendar"},
|
||||
{"id": "materials", "label": "Materialien", "icon": "folder_open", "url": "/materials"},
|
||||
{"id": "settings", "label": "Einstellungen", "icon": "settings", "url": "/settings"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/path")
|
||||
async def get_schoolyear_path(teacher_id: str = Query(...), db=Depends(get_db)):
|
||||
"""Generiert den Schuljahres-Pfad mit Meilensteinen."""
|
||||
current_phase = "onboarding"
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherContextRepository
|
||||
repo = TeacherContextRepository(db)
|
||||
context = repo.get_or_create(teacher_id)
|
||||
current_phase = context.macro_phase.value
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get context for path: {e}")
|
||||
|
||||
phase_order = [
|
||||
"onboarding", "schuljahresstart", "unterrichtsaufbau",
|
||||
"leistungsphase_1", "halbjahresabschluss", "leistungsphase_2", "jahresabschluss",
|
||||
]
|
||||
|
||||
current_index = phase_order.index(current_phase) if current_phase in phase_order else 0
|
||||
|
||||
milestones = [
|
||||
{"id": "MS_START", "label": "Start", "phase": "onboarding", "icon": "flag"},
|
||||
{"id": "MS_SETUP", "label": "Einrichtung", "phase": "schuljahresstart", "icon": "tune"},
|
||||
{"id": "MS_ROUTINE", "label": "Routinen", "phase": "unterrichtsaufbau", "icon": "repeat"},
|
||||
{"id": "MS_EXAM_1", "label": "Klausuren", "phase": "leistungsphase_1", "icon": "quiz"},
|
||||
{"id": "MS_HALFYEAR", "label": "Halbjahr", "phase": "halbjahresabschluss", "icon": "event"},
|
||||
{"id": "MS_EXAM_2", "label": "Pruefungen", "phase": "leistungsphase_2", "icon": "school"},
|
||||
{"id": "MS_END", "label": "Abschluss", "phase": "jahresabschluss", "icon": "celebration"},
|
||||
]
|
||||
|
||||
for milestone in milestones:
|
||||
phase = milestone["phase"]
|
||||
phase_index = phase_order.index(phase) if phase in phase_order else 999
|
||||
if phase_index < current_index:
|
||||
milestone["status"] = "done"
|
||||
elif phase_index == current_index:
|
||||
milestone["status"] = "current"
|
||||
else:
|
||||
milestone["status"] = "upcoming"
|
||||
|
||||
current_milestone_id = next(
|
||||
(m["id"] for m in milestones if m["status"] == "current"),
|
||||
milestones[0]["id"]
|
||||
)
|
||||
|
||||
progress = int((current_index / (len(phase_order) - 1)) * 100) if len(phase_order) > 1 else 0
|
||||
|
||||
return {
|
||||
"milestones": milestones,
|
||||
"current_milestone_id": current_milestone_id,
|
||||
"progress_percent": progress,
|
||||
"current_phase": current_phase,
|
||||
}
|
||||
358
backend/classroom/routes/export.py
Normal file
358
backend/classroom/routes/export.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
Classroom API - Export Routes
|
||||
|
||||
PDF/HTML export endpoints (Phase 5).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from classroom_engine import LessonPhase
|
||||
|
||||
from ..services.persistence import (
|
||||
sessions,
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Export"])
|
||||
|
||||
|
||||
@router.get("/export/session/{session_id}", response_class=HTMLResponse)
|
||||
async def export_session_html(session_id: str) -> HTMLResponse:
|
||||
"""
|
||||
Exportiert eine Session-Zusammenfassung als druckbares HTML.
|
||||
|
||||
Kann im Browser ueber Strg+P als PDF gespeichert werden.
|
||||
"""
|
||||
# Session-Daten aus Memory oder DB holen
|
||||
session = sessions.get(session_id)
|
||||
|
||||
if not session and DB_ENABLED:
|
||||
init_db_if_needed()
|
||||
with SessionLocal() as db:
|
||||
from classroom_engine.repository import AnalyticsRepository
|
||||
repo = AnalyticsRepository(db)
|
||||
summary = repo.get_session_summary(session_id)
|
||||
|
||||
if not summary:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
|
||||
# HTML generieren aus Summary
|
||||
return HTMLResponse(content=_generate_export_html_from_summary(summary))
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
|
||||
# HTML aus In-Memory Session generieren
|
||||
return HTMLResponse(content=_generate_export_html_from_session(session))
|
||||
|
||||
|
||||
def _generate_export_html_from_summary(summary) -> str:
|
||||
"""Generiert druckbares HTML aus einer SessionSummary."""
|
||||
phases_html = ""
|
||||
for phase in summary.phase_statistics:
|
||||
diff_class = "on-time"
|
||||
if phase.difference_seconds < -60:
|
||||
diff_class = "under-time"
|
||||
elif phase.difference_seconds > 180:
|
||||
diff_class = "way-over"
|
||||
elif phase.difference_seconds > 60:
|
||||
diff_class = "over-time"
|
||||
|
||||
phases_html += f"""
|
||||
<tr>
|
||||
<td>{phase.display_name}</td>
|
||||
<td class="center">{phase.planned_duration_seconds // 60}:{phase.planned_duration_seconds % 60:02d}</td>
|
||||
<td class="center">{phase.actual_duration_seconds // 60}:{phase.actual_duration_seconds % 60:02d}</td>
|
||||
<td class="center {diff_class}">{phase.difference_formatted}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
return _get_export_html_template(
|
||||
subject=summary.subject,
|
||||
class_id=summary.class_id,
|
||||
topic=summary.topic,
|
||||
date_formatted=summary.date_formatted,
|
||||
total_duration_formatted=summary.total_duration_formatted,
|
||||
phases_completed=summary.phases_completed,
|
||||
total_phases=summary.total_phases,
|
||||
total_overtime_formatted=summary.total_overtime_formatted,
|
||||
phases_html=phases_html,
|
||||
phases_with_overtime=summary.phases_with_overtime,
|
||||
total_overtime_seconds=summary.total_overtime_seconds,
|
||||
reflection_notes=summary.reflection_notes,
|
||||
)
|
||||
|
||||
|
||||
def _generate_export_html_from_session(session) -> str:
|
||||
"""Generiert druckbares HTML aus einer In-Memory Session."""
|
||||
# Phasen-Tabelle generieren
|
||||
phases_html = ""
|
||||
total_overtime = 0
|
||||
|
||||
for entry in session.phase_history:
|
||||
phase = entry.get("phase", "")
|
||||
if phase in ["not_started", "ended"]:
|
||||
continue
|
||||
|
||||
planned = session.phase_durations.get(phase, 0) * 60
|
||||
actual = entry.get("duration_seconds", 0) or 0
|
||||
diff = actual - planned
|
||||
|
||||
if diff > 0:
|
||||
total_overtime += diff
|
||||
|
||||
diff_class = "on-time"
|
||||
if diff < -60:
|
||||
diff_class = "under-time"
|
||||
elif diff > 180:
|
||||
diff_class = "way-over"
|
||||
elif diff > 60:
|
||||
diff_class = "over-time"
|
||||
|
||||
phase_names = {
|
||||
"einstieg": "Einstieg",
|
||||
"erarbeitung": "Erarbeitung",
|
||||
"sicherung": "Sicherung",
|
||||
"transfer": "Transfer",
|
||||
"reflexion": "Reflexion",
|
||||
}
|
||||
|
||||
phases_html += f"""
|
||||
<tr>
|
||||
<td>{phase_names.get(phase, phase)}</td>
|
||||
<td class="center">{planned // 60}:{planned % 60:02d}</td>
|
||||
<td class="center">{actual // 60}:{actual % 60:02d}</td>
|
||||
<td class="center {diff_class}">{'+' if diff >= 0 else ''}{diff // 60}:{abs(diff) % 60:02d}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
# Zeiten
|
||||
total_duration = 0
|
||||
date_str = "--"
|
||||
if session.lesson_started_at:
|
||||
date_str = session.lesson_started_at.strftime("%d.%m.%Y %H:%M")
|
||||
if session.lesson_ended_at:
|
||||
total_duration = int((session.lesson_ended_at - session.lesson_started_at).total_seconds())
|
||||
|
||||
total_mins = total_duration // 60
|
||||
total_secs = total_duration % 60
|
||||
overtime_mins = total_overtime // 60
|
||||
overtime_secs = total_overtime % 60
|
||||
|
||||
completed_phases = len([e for e in session.phase_history if e.get("ended_at")])
|
||||
|
||||
return _get_export_html_template(
|
||||
subject=session.subject,
|
||||
class_id=session.class_id,
|
||||
topic=session.topic,
|
||||
date_formatted=date_str,
|
||||
total_duration_formatted=f"{total_mins:02d}:{total_secs:02d}",
|
||||
phases_completed=completed_phases,
|
||||
total_phases=5,
|
||||
total_overtime_formatted=f"{overtime_mins:02d}:{overtime_secs:02d}",
|
||||
phases_html=phases_html,
|
||||
phases_with_overtime=len([e for e in session.phase_history if e.get("duration_seconds", 0) > session.phase_durations.get(e.get("phase", ""), 0) * 60]),
|
||||
total_overtime_seconds=total_overtime,
|
||||
reflection_notes="",
|
||||
)
|
||||
|
||||
|
||||
def _get_export_html_template(
|
||||
subject: str,
|
||||
class_id: str,
|
||||
topic: str,
|
||||
date_formatted: str,
|
||||
total_duration_formatted: str,
|
||||
phases_completed: int,
|
||||
total_phases: int,
|
||||
total_overtime_formatted: str,
|
||||
phases_html: str,
|
||||
phases_with_overtime: int,
|
||||
total_overtime_seconds: int,
|
||||
reflection_notes: str,
|
||||
) -> str:
|
||||
"""Returns the full HTML template for export."""
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stundenprotokoll - {subject}</title>
|
||||
<style>
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
.header {{
|
||||
border-bottom: 2px solid #1a1a2e;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.title {{
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
color: #1a1a2e;
|
||||
margin: 0;
|
||||
}}
|
||||
.subtitle {{
|
||||
color: #666;
|
||||
font-size: 12pt;
|
||||
margin-top: 5px;
|
||||
}}
|
||||
.meta-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 25px;
|
||||
}}
|
||||
.meta-item {{
|
||||
background: #f5f5f5;
|
||||
padding: 12px 15px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
.meta-label {{
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}}
|
||||
.meta-value {{
|
||||
font-size: 14pt;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 25px;
|
||||
}}
|
||||
th, td {{
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}}
|
||||
th {{
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}}
|
||||
tr:nth-child(even) {{
|
||||
background: #f9f9f9;
|
||||
}}
|
||||
.center {{
|
||||
text-align: center;
|
||||
}}
|
||||
.on-time {{ color: #3b82f6; }}
|
||||
.under-time {{ color: #10b981; }}
|
||||
.over-time {{ color: #f59e0b; }}
|
||||
.way-over {{ color: #ef4444; }}
|
||||
.summary-box {{
|
||||
background: #fff8e1;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 15px;
|
||||
margin-bottom: 25px;
|
||||
}}
|
||||
.footer {{
|
||||
margin-top: 40px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}}
|
||||
@media print {{
|
||||
body {{
|
||||
padding: 0;
|
||||
}}
|
||||
.no-print {{
|
||||
display: none;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 class="title">Stundenprotokoll</h1>
|
||||
<p class="subtitle">{subject} - Klasse {class_id}{f" - {topic}" if topic else ""}</p>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Datum</div>
|
||||
<div class="meta-value">{date_formatted}</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Gesamtdauer</div>
|
||||
<div class="meta-value">{total_duration_formatted}</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Phasen abgeschlossen</div>
|
||||
<div class="meta-value">{phases_completed}/{total_phases}</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Overtime gesamt</div>
|
||||
<div class="meta-value">{total_overtime_formatted}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Phasen-Analyse</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Phase</th>
|
||||
<th class="center">Geplant</th>
|
||||
<th class="center">Tatsaechlich</th>
|
||||
<th class="center">Differenz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{phases_html}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{f'''
|
||||
<div class="summary-box">
|
||||
<strong>Overtime-Zusammenfassung:</strong><br>
|
||||
{phases_with_overtime} von {total_phases} Phasen hatten Overtime
|
||||
(gesamt: {total_overtime_formatted})
|
||||
</div>
|
||||
''' if total_overtime_seconds > 0 else ''}
|
||||
|
||||
{f'''
|
||||
<h2>Reflexion</h2>
|
||||
<p>{reflection_notes}</p>
|
||||
''' if reflection_notes else ''}
|
||||
|
||||
<div class="footer">
|
||||
<p>Erstellt mit BreakPilot Classroom Engine | {datetime.utcnow().strftime("%d.%m.%Y %H:%M")}</p>
|
||||
<p class="no-print" style="margin-top: 10px;">
|
||||
<button onclick="window.print()" style="padding: 10px 20px; cursor: pointer;">
|
||||
Als PDF speichern (Strg+P)
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
280
backend/classroom/routes/feedback.py
Normal file
280
backend/classroom/routes/feedback.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Classroom API - Feedback Routes
|
||||
|
||||
Teacher feedback endpoints (Phase 7).
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from ..models import (
|
||||
FeedbackCreate,
|
||||
FeedbackResponse,
|
||||
FeedbackListResponse,
|
||||
FeedbackStatsResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Feedback"])
|
||||
|
||||
# In-Memory Fallback wenn DB nicht verfuegbar
|
||||
_feedback_store: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
async def get_optional_current_user(request: Request) -> Dict[str, Any]:
|
||||
"""Gets current user from auth token if available."""
|
||||
# Simplified - in production this would check JWT token
|
||||
return {
|
||||
"user_id": "anonymous",
|
||||
"name": "",
|
||||
"email": "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/feedback", response_model=FeedbackResponse, status_code=201)
|
||||
async def create_feedback(
|
||||
data: FeedbackCreate,
|
||||
request: Request,
|
||||
teacher_id: Optional[str] = Query(None, description="Lehrer-ID (optional, wird aus Auth Token gelesen)")
|
||||
) -> FeedbackResponse:
|
||||
"""
|
||||
Erstellt neues Lehrer-Feedback.
|
||||
|
||||
Ermoeglicht Lehrern, Bug-Reports, Feature-Requests und Verbesserungsvorschlaege
|
||||
direkt aus dem Lehrer-Frontend zu senden.
|
||||
|
||||
Authentifizierung optional - wenn eingeloggt, wird User-ID automatisch verwendet.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# Auth: User aus Token holen oder Demo-User verwenden
|
||||
user = await get_optional_current_user(request)
|
||||
effective_teacher_id = teacher_id or user.get("user_id", "anonymous")
|
||||
|
||||
feedback_id = str(uuid4())
|
||||
created_at = datetime.utcnow()
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherFeedbackRepository
|
||||
with SessionLocal() as db:
|
||||
repo = TeacherFeedbackRepository(db)
|
||||
db_feedback = repo.create(
|
||||
teacher_id=effective_teacher_id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
feedback_type=data.feedback_type,
|
||||
priority=data.priority,
|
||||
teacher_name=data.teacher_name or user.get("name", ""),
|
||||
teacher_email=data.teacher_email or user.get("email", ""),
|
||||
context_url=data.context_url,
|
||||
context_phase=data.context_phase,
|
||||
context_session_id=data.context_session_id,
|
||||
related_feature=data.related_feature,
|
||||
)
|
||||
return FeedbackResponse(
|
||||
id=db_feedback.id,
|
||||
teacher_id=db_feedback.teacher_id,
|
||||
teacher_name=db_feedback.teacher_name,
|
||||
title=db_feedback.title,
|
||||
description=db_feedback.description,
|
||||
feedback_type=db_feedback.feedback_type.value,
|
||||
priority=db_feedback.priority.value,
|
||||
status=db_feedback.status.value,
|
||||
created_at=db_feedback.created_at.isoformat(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create feedback in DB: {e}")
|
||||
|
||||
# Fallback: In-Memory Storage
|
||||
feedback = {
|
||||
"id": feedback_id,
|
||||
"teacher_id": effective_teacher_id,
|
||||
"teacher_name": data.teacher_name,
|
||||
"teacher_email": data.teacher_email,
|
||||
"title": data.title,
|
||||
"description": data.description,
|
||||
"feedback_type": data.feedback_type,
|
||||
"priority": data.priority,
|
||||
"status": "new",
|
||||
"related_feature": data.related_feature,
|
||||
"context_url": data.context_url,
|
||||
"context_phase": data.context_phase,
|
||||
"context_session_id": data.context_session_id,
|
||||
"response": None,
|
||||
"created_at": created_at.isoformat(),
|
||||
"updated_at": created_at.isoformat(),
|
||||
}
|
||||
_feedback_store.append(feedback)
|
||||
|
||||
return FeedbackResponse(
|
||||
id=feedback_id,
|
||||
teacher_id=effective_teacher_id,
|
||||
teacher_name=data.teacher_name or user.get("name", ""),
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
feedback_type=data.feedback_type,
|
||||
priority=data.priority,
|
||||
status="new",
|
||||
created_at=created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feedback", response_model=FeedbackListResponse)
|
||||
async def list_feedback(
|
||||
status: Optional[str] = Query(None, description="Filter nach Status"),
|
||||
feedback_type: Optional[str] = Query(None, description="Filter nach Typ"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0)
|
||||
) -> FeedbackListResponse:
|
||||
"""
|
||||
Listet alle Feedbacks (fuer Developer Dashboard).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherFeedbackRepository
|
||||
with SessionLocal() as db:
|
||||
repo = TeacherFeedbackRepository(db)
|
||||
feedbacks = repo.get_all(
|
||||
status=status,
|
||||
feedback_type=feedback_type,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
return FeedbackListResponse(
|
||||
feedbacks=[repo.to_dict(fb) for fb in feedbacks],
|
||||
total=len(feedbacks)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list feedback from DB: {e}")
|
||||
|
||||
# Fallback: In-Memory
|
||||
result = _feedback_store
|
||||
if status:
|
||||
result = [fb for fb in result if fb.get("status") == status]
|
||||
if feedback_type:
|
||||
result = [fb for fb in result if fb.get("feedback_type") == feedback_type]
|
||||
|
||||
return FeedbackListResponse(
|
||||
feedbacks=result[offset:offset + limit],
|
||||
total=len(result)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feedback/stats", response_model=FeedbackStatsResponse)
|
||||
async def get_feedback_stats() -> FeedbackStatsResponse:
|
||||
"""
|
||||
Gibt Feedback-Statistiken zurueck.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherFeedbackRepository
|
||||
with SessionLocal() as db:
|
||||
repo = TeacherFeedbackRepository(db)
|
||||
stats = repo.get_stats()
|
||||
return FeedbackStatsResponse(**stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get feedback stats: {e}")
|
||||
|
||||
# Fallback: In-Memory
|
||||
stats = {
|
||||
"total": len(_feedback_store),
|
||||
"by_status": {},
|
||||
"by_type": {},
|
||||
"by_priority": {},
|
||||
}
|
||||
for fb in _feedback_store:
|
||||
stats["by_status"][fb["status"]] = stats["by_status"].get(fb["status"], 0) + 1
|
||||
stats["by_type"][fb["feedback_type"]] = stats["by_type"].get(fb["feedback_type"], 0) + 1
|
||||
stats["by_priority"][fb["priority"]] = stats["by_priority"].get(fb["priority"], 0) + 1
|
||||
|
||||
return FeedbackStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.get("/feedback/{feedback_id}")
|
||||
async def get_feedback(feedback_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Ruft ein einzelnes Feedback ab.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherFeedbackRepository
|
||||
with SessionLocal() as db:
|
||||
repo = TeacherFeedbackRepository(db)
|
||||
db_feedback = repo.get_by_id(feedback_id)
|
||||
if db_feedback:
|
||||
return repo.to_dict(db_feedback)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get feedback: {e}")
|
||||
|
||||
# Fallback: In-Memory
|
||||
for fb in _feedback_store:
|
||||
if fb["id"] == feedback_id:
|
||||
return fb
|
||||
|
||||
raise HTTPException(status_code=404, detail="Feedback nicht gefunden")
|
||||
|
||||
|
||||
@router.put("/feedback/{feedback_id}/status")
|
||||
async def update_feedback_status(
|
||||
feedback_id: str,
|
||||
status: str = Query(..., description="Neuer Status"),
|
||||
response: Optional[str] = Query(None, description="Antwort"),
|
||||
responded_by: Optional[str] = Query(None, description="Wer antwortet")
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Aktualisiert den Status eines Feedbacks (fuer Entwickler).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
valid_statuses = ["new", "acknowledged", "planned", "implemented", "declined"]
|
||||
if status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungueltiger Status. Erlaubt: {valid_statuses}"
|
||||
)
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherFeedbackRepository
|
||||
with SessionLocal() as db:
|
||||
repo = TeacherFeedbackRepository(db)
|
||||
db_feedback = repo.update_status(
|
||||
feedback_id=feedback_id,
|
||||
status=status,
|
||||
response=response,
|
||||
responded_by=responded_by
|
||||
)
|
||||
if db_feedback:
|
||||
return repo.to_dict(db_feedback)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update feedback status: {e}")
|
||||
|
||||
# Fallback: In-Memory
|
||||
for fb in _feedback_store:
|
||||
if fb["id"] == feedback_id:
|
||||
fb["status"] = status
|
||||
if response:
|
||||
fb["response"] = response
|
||||
fb["responded_by"] = responded_by
|
||||
fb["responded_at"] = datetime.utcnow().isoformat()
|
||||
fb["updated_at"] = datetime.utcnow().isoformat()
|
||||
return fb
|
||||
|
||||
raise HTTPException(status_code=404, detail="Feedback nicht gefunden")
|
||||
284
backend/classroom/routes/homework.py
Normal file
284
backend/classroom/routes/homework.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Classroom API - Homework Routes
|
||||
|
||||
Homework tracking endpoints (Feature f20).
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from classroom_engine import (
|
||||
Homework,
|
||||
HomeworkStatus,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
CreateHomeworkRequest,
|
||||
UpdateHomeworkRequest,
|
||||
HomeworkResponse,
|
||||
HomeworkListResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Homework"])
|
||||
|
||||
# In-Memory Storage fuer Hausaufgaben (Fallback)
|
||||
_homework: Dict[str, Homework] = {}
|
||||
|
||||
|
||||
def _build_homework_response(hw: Homework) -> HomeworkResponse:
|
||||
"""Baut eine HomeworkResponse aus einem Homework-Objekt."""
|
||||
is_overdue = False
|
||||
if hw.due_date and hw.status != HomeworkStatus.COMPLETED:
|
||||
is_overdue = hw.due_date < datetime.utcnow()
|
||||
|
||||
return HomeworkResponse(
|
||||
homework_id=hw.homework_id,
|
||||
teacher_id=hw.teacher_id,
|
||||
class_id=hw.class_id,
|
||||
subject=hw.subject,
|
||||
title=hw.title,
|
||||
description=hw.description,
|
||||
session_id=hw.session_id,
|
||||
due_date=hw.due_date.isoformat() if hw.due_date else None,
|
||||
status=hw.status.value,
|
||||
is_overdue=is_overdue,
|
||||
created_at=hw.created_at.isoformat() if hw.created_at else None,
|
||||
updated_at=hw.updated_at.isoformat() if hw.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/homework", response_model=HomeworkResponse, status_code=201)
|
||||
async def create_homework(request: CreateHomeworkRequest) -> HomeworkResponse:
|
||||
"""
|
||||
Erstellt eine neue Hausaufgabe (Feature f20).
|
||||
|
||||
Kann mit einer Session verknuepft werden.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
due_date = None
|
||||
if request.due_date:
|
||||
try:
|
||||
due_date = datetime.fromisoformat(request.due_date.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Ungueltiges Datumsformat")
|
||||
|
||||
homework = Homework(
|
||||
homework_id=str(uuid4()),
|
||||
teacher_id=request.teacher_id,
|
||||
class_id=request.class_id,
|
||||
subject=request.subject,
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
session_id=request.session_id,
|
||||
due_date=due_date,
|
||||
status=HomeworkStatus.ASSIGNED,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Persistieren wenn DB verfuegbar
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import HomeworkRepository
|
||||
db = SessionLocal()
|
||||
repo = HomeworkRepository(db)
|
||||
repo.create(homework)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB persist failed for homework: {e}")
|
||||
|
||||
_homework[homework.homework_id] = homework
|
||||
return _build_homework_response(homework)
|
||||
|
||||
|
||||
@router.get("/homework", response_model=HomeworkListResponse)
|
||||
async def list_homework(
|
||||
teacher_id: str = Query(None, description="Filter nach Lehrer"),
|
||||
class_id: str = Query(None, description="Filter nach Klasse"),
|
||||
status: Optional[str] = Query(None, description="Filter nach Status: assigned, in_progress, completed"),
|
||||
limit: int = Query(50, ge=1, le=100)
|
||||
) -> HomeworkListResponse:
|
||||
"""
|
||||
Listet Hausaufgaben mit optionalen Filtern (Feature f20).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
homework_list = []
|
||||
|
||||
# Aus DB laden wenn verfuegbar
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import HomeworkRepository
|
||||
db = SessionLocal()
|
||||
repo = HomeworkRepository(db)
|
||||
db_homework = repo.get_by_filters(
|
||||
teacher_id=teacher_id,
|
||||
class_id=class_id,
|
||||
status=status,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
for db_hw in db_homework:
|
||||
hw = repo.to_dataclass(db_hw)
|
||||
_homework[hw.homework_id] = hw
|
||||
homework_list.append(_build_homework_response(hw))
|
||||
|
||||
db.close()
|
||||
return HomeworkListResponse(homework=homework_list, total=len(homework_list))
|
||||
except Exception as e:
|
||||
logger.warning(f"DB read failed for homework: {e}")
|
||||
|
||||
# Fallback auf Memory
|
||||
for hw in _homework.values():
|
||||
if teacher_id and hw.teacher_id != teacher_id:
|
||||
continue
|
||||
if class_id and hw.class_id != class_id:
|
||||
continue
|
||||
if status:
|
||||
try:
|
||||
filter_status = HomeworkStatus(status)
|
||||
if hw.status != filter_status:
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
homework_list.append(_build_homework_response(hw))
|
||||
|
||||
return HomeworkListResponse(homework=homework_list[:limit], total=len(homework_list))
|
||||
|
||||
|
||||
@router.get("/homework/{homework_id}", response_model=HomeworkResponse)
|
||||
async def get_homework(homework_id: str) -> HomeworkResponse:
|
||||
"""
|
||||
Ruft eine einzelne Hausaufgabe ab (Feature f20).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# Aus Memory
|
||||
if homework_id in _homework:
|
||||
return _build_homework_response(_homework[homework_id])
|
||||
|
||||
# Aus DB laden
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import HomeworkRepository
|
||||
db = SessionLocal()
|
||||
repo = HomeworkRepository(db)
|
||||
db_hw = repo.get_by_id(homework_id)
|
||||
db.close()
|
||||
if db_hw:
|
||||
hw = repo.to_dataclass(db_hw)
|
||||
_homework[hw.homework_id] = hw
|
||||
return _build_homework_response(hw)
|
||||
except Exception as e:
|
||||
logger.warning(f"DB read failed: {e}")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Hausaufgabe nicht gefunden")
|
||||
|
||||
|
||||
@router.put("/homework/{homework_id}", response_model=HomeworkResponse)
|
||||
async def update_homework(
|
||||
homework_id: str,
|
||||
request: UpdateHomeworkRequest
|
||||
) -> HomeworkResponse:
|
||||
"""
|
||||
Aktualisiert eine Hausaufgabe (Feature f20).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# Aus Memory holen
|
||||
homework = _homework.get(homework_id)
|
||||
|
||||
# Aus DB laden wenn nicht in Memory
|
||||
if not homework and DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import HomeworkRepository
|
||||
db = SessionLocal()
|
||||
repo = HomeworkRepository(db)
|
||||
db_hw = repo.get_by_id(homework_id)
|
||||
db.close()
|
||||
if db_hw:
|
||||
homework = repo.to_dataclass(db_hw)
|
||||
_homework[homework.homework_id] = homework
|
||||
except Exception as e:
|
||||
logger.warning(f"DB read failed: {e}")
|
||||
|
||||
if not homework:
|
||||
raise HTTPException(status_code=404, detail="Hausaufgabe nicht gefunden")
|
||||
|
||||
# Aktualisieren
|
||||
if request.title is not None:
|
||||
homework.title = request.title
|
||||
if request.description is not None:
|
||||
homework.description = request.description
|
||||
if request.due_date is not None:
|
||||
try:
|
||||
homework.due_date = datetime.fromisoformat(request.due_date.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Ungueltiges Datumsformat")
|
||||
if request.status is not None:
|
||||
try:
|
||||
homework.status = HomeworkStatus(request.status)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Ungueltiger Status")
|
||||
|
||||
homework.updated_at = datetime.utcnow()
|
||||
|
||||
# In DB aktualisieren
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import HomeworkRepository
|
||||
db = SessionLocal()
|
||||
repo = HomeworkRepository(db)
|
||||
repo.update(homework)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB update failed: {e}")
|
||||
|
||||
_homework[homework_id] = homework
|
||||
return _build_homework_response(homework)
|
||||
|
||||
|
||||
@router.patch("/homework/{homework_id}/status")
|
||||
async def update_homework_status(
|
||||
homework_id: str,
|
||||
status: str = Query(..., description="Neuer Status: assigned, in_progress, completed")
|
||||
) -> HomeworkResponse:
|
||||
"""
|
||||
Aktualisiert nur den Status einer Hausaufgabe (Feature f20).
|
||||
"""
|
||||
return await update_homework(homework_id, UpdateHomeworkRequest(status=status))
|
||||
|
||||
|
||||
@router.delete("/homework/{homework_id}")
|
||||
async def delete_homework(homework_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Loescht eine Hausaufgabe (Feature f20).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
if homework_id in _homework:
|
||||
del _homework[homework_id]
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import HomeworkRepository
|
||||
db = SessionLocal()
|
||||
repo = HomeworkRepository(db)
|
||||
repo.delete(homework_id)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB delete failed: {e}")
|
||||
|
||||
return {"status": "deleted", "homework_id": homework_id}
|
||||
329
backend/classroom/routes/materials.py
Normal file
329
backend/classroom/routes/materials.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Classroom API - Materials Routes
|
||||
|
||||
Phase materials management endpoints (Feature f19).
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from classroom_engine import (
|
||||
PhaseMaterial,
|
||||
MaterialType,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
CreateMaterialRequest,
|
||||
UpdateMaterialRequest,
|
||||
MaterialResponse,
|
||||
MaterialListResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Materials"])
|
||||
|
||||
# In-Memory Storage fuer Materialien (Fallback)
|
||||
_materials: Dict[str, PhaseMaterial] = {}
|
||||
|
||||
|
||||
def _build_material_response(mat: PhaseMaterial) -> MaterialResponse:
|
||||
"""Baut eine MaterialResponse aus einem PhaseMaterial-Objekt."""
|
||||
return MaterialResponse(
|
||||
material_id=mat.material_id,
|
||||
teacher_id=mat.teacher_id,
|
||||
title=mat.title,
|
||||
material_type=mat.material_type.value,
|
||||
url=mat.url,
|
||||
description=mat.description,
|
||||
phase=mat.phase,
|
||||
subject=mat.subject,
|
||||
grade_level=mat.grade_level,
|
||||
tags=mat.tags,
|
||||
is_public=mat.is_public,
|
||||
usage_count=mat.usage_count,
|
||||
session_id=mat.session_id,
|
||||
created_at=mat.created_at.isoformat() if mat.created_at else None,
|
||||
updated_at=mat.updated_at.isoformat() if mat.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/materials", response_model=MaterialResponse, status_code=201)
|
||||
async def create_material(request: CreateMaterialRequest) -> MaterialResponse:
|
||||
"""
|
||||
Erstellt ein neues Material (Feature f19).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
try:
|
||||
mat_type = MaterialType(request.material_type)
|
||||
except ValueError:
|
||||
mat_type = MaterialType.DOCUMENT
|
||||
|
||||
material = PhaseMaterial(
|
||||
material_id=str(uuid4()),
|
||||
teacher_id=request.teacher_id,
|
||||
title=request.title,
|
||||
material_type=mat_type,
|
||||
url=request.url,
|
||||
description=request.description,
|
||||
phase=request.phase,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
tags=request.tags,
|
||||
is_public=request.is_public,
|
||||
usage_count=0,
|
||||
session_id=request.session_id,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Persistieren wenn DB verfuegbar
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
repo.create(material)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB persist failed for material: {e}")
|
||||
|
||||
_materials[material.material_id] = material
|
||||
return _build_material_response(material)
|
||||
|
||||
|
||||
@router.get("/materials", response_model=MaterialListResponse)
|
||||
async def list_materials(
|
||||
teacher_id: str = Query(..., description="ID des Lehrers"),
|
||||
phase: Optional[str] = Query(None, description="Filter nach Phase"),
|
||||
subject: Optional[str] = Query(None, description="Filter nach Fach"),
|
||||
include_public: bool = Query(True, description="Oeffentliche Materialien einbeziehen"),
|
||||
limit: int = Query(50, ge=1, le=100)
|
||||
) -> MaterialListResponse:
|
||||
"""
|
||||
Listet Materialien eines Lehrers (Feature f19).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
materials_list = []
|
||||
|
||||
# Aus DB laden wenn verfuegbar
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
if phase:
|
||||
db_materials = repo.get_by_phase(phase, teacher_id, include_public)
|
||||
else:
|
||||
db_materials = repo.get_by_teacher(teacher_id, phase, subject, limit)
|
||||
|
||||
for db_mat in db_materials:
|
||||
mat = repo.to_dataclass(db_mat)
|
||||
_materials[mat.material_id] = mat
|
||||
materials_list.append(_build_material_response(mat))
|
||||
db.close()
|
||||
return MaterialListResponse(materials=materials_list, total=len(materials_list))
|
||||
except Exception as e:
|
||||
logger.warning(f"DB read failed for materials: {e}")
|
||||
|
||||
# Fallback auf Memory
|
||||
for mat in _materials.values():
|
||||
if mat.teacher_id != teacher_id and not (include_public and mat.is_public):
|
||||
continue
|
||||
if phase and mat.phase != phase:
|
||||
continue
|
||||
if subject and mat.subject != subject:
|
||||
continue
|
||||
materials_list.append(_build_material_response(mat))
|
||||
|
||||
return MaterialListResponse(materials=materials_list[:limit], total=len(materials_list))
|
||||
|
||||
|
||||
@router.get("/materials/by-phase/{phase}", response_model=MaterialListResponse)
|
||||
async def get_materials_by_phase(
|
||||
phase: str,
|
||||
teacher_id: str = Query(..., description="ID des Lehrers"),
|
||||
subject: Optional[str] = Query(None, description="Filter nach Fach"),
|
||||
limit: int = Query(50, ge=1, le=100)
|
||||
) -> MaterialListResponse:
|
||||
"""
|
||||
Holt Materialien fuer eine bestimmte Phase (Feature f19).
|
||||
"""
|
||||
return await list_materials(teacher_id=teacher_id, phase=phase, subject=subject, limit=limit)
|
||||
|
||||
|
||||
@router.get("/materials/{material_id}", response_model=MaterialResponse)
|
||||
async def get_material(material_id: str) -> MaterialResponse:
|
||||
"""
|
||||
Ruft ein einzelnes Material ab (Feature f19).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# Aus Memory
|
||||
if material_id in _materials:
|
||||
return _build_material_response(_materials[material_id])
|
||||
|
||||
# Aus DB laden
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
db_mat = repo.get_by_id(material_id)
|
||||
db.close()
|
||||
if db_mat:
|
||||
mat = repo.to_dataclass(db_mat)
|
||||
_materials[mat.material_id] = mat
|
||||
return _build_material_response(mat)
|
||||
except Exception as e:
|
||||
logger.warning(f"DB read failed: {e}")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Material nicht gefunden")
|
||||
|
||||
|
||||
@router.put("/materials/{material_id}", response_model=MaterialResponse)
|
||||
async def update_material(
|
||||
material_id: str,
|
||||
request: UpdateMaterialRequest
|
||||
) -> MaterialResponse:
|
||||
"""
|
||||
Aktualisiert ein Material (Feature f19).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# Aus Memory holen
|
||||
material = _materials.get(material_id)
|
||||
|
||||
# Aus DB laden wenn nicht in Memory
|
||||
if not material and DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
db_mat = repo.get_by_id(material_id)
|
||||
db.close()
|
||||
if db_mat:
|
||||
material = repo.to_dataclass(db_mat)
|
||||
_materials[material.material_id] = material
|
||||
except Exception as e:
|
||||
logger.warning(f"DB read failed: {e}")
|
||||
|
||||
if not material:
|
||||
raise HTTPException(status_code=404, detail="Material nicht gefunden")
|
||||
|
||||
# Aktualisieren
|
||||
if request.title is not None:
|
||||
material.title = request.title
|
||||
if request.material_type is not None:
|
||||
try:
|
||||
material.material_type = MaterialType(request.material_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Ungueltiger Material-Typ")
|
||||
if request.url is not None:
|
||||
material.url = request.url
|
||||
if request.description is not None:
|
||||
material.description = request.description
|
||||
if request.phase is not None:
|
||||
material.phase = request.phase
|
||||
if request.subject is not None:
|
||||
material.subject = request.subject
|
||||
if request.grade_level is not None:
|
||||
material.grade_level = request.grade_level
|
||||
if request.tags is not None:
|
||||
material.tags = request.tags
|
||||
if request.is_public is not None:
|
||||
material.is_public = request.is_public
|
||||
|
||||
material.updated_at = datetime.utcnow()
|
||||
|
||||
# In DB aktualisieren
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
repo.update(material)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB update failed: {e}")
|
||||
|
||||
_materials[material_id] = material
|
||||
return _build_material_response(material)
|
||||
|
||||
|
||||
@router.post("/materials/{material_id}/attach/{session_id}")
|
||||
async def attach_material_to_session(
|
||||
material_id: str,
|
||||
session_id: str
|
||||
) -> MaterialResponse:
|
||||
"""
|
||||
Verknuepft ein Material mit einer Session (Feature f19).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
material = _materials.get(material_id)
|
||||
|
||||
if not material and DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
db_mat = repo.get_by_id(material_id)
|
||||
if db_mat:
|
||||
material = repo.to_dataclass(db_mat)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB read failed: {e}")
|
||||
|
||||
if not material:
|
||||
raise HTTPException(status_code=404, detail="Material nicht gefunden")
|
||||
|
||||
material.session_id = session_id
|
||||
material.usage_count += 1
|
||||
material.updated_at = datetime.utcnow()
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
repo.attach_to_session(material_id, session_id)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB update failed: {e}")
|
||||
|
||||
_materials[material_id] = material
|
||||
return _build_material_response(material)
|
||||
|
||||
|
||||
@router.delete("/materials/{material_id}")
|
||||
async def delete_material(material_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Loescht ein Material (Feature f19).
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
if material_id in _materials:
|
||||
del _materials[material_id]
|
||||
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import MaterialRepository
|
||||
db = SessionLocal()
|
||||
repo = MaterialRepository(db)
|
||||
repo.delete(material_id)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB delete failed: {e}")
|
||||
|
||||
return {"status": "deleted", "material_id": material_id}
|
||||
525
backend/classroom/routes/sessions.py
Normal file
525
backend/classroom/routes/sessions.py
Normal file
@@ -0,0 +1,525 @@
|
||||
"""
|
||||
Classroom API - Session Routes
|
||||
|
||||
Session management endpoints: create, get, start, next-phase, end, etc.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
from typing import Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from classroom_engine import (
|
||||
LessonPhase,
|
||||
LessonSession,
|
||||
LessonStateMachine,
|
||||
PhaseTimer,
|
||||
SuggestionEngine,
|
||||
LESSON_PHASES,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
CreateSessionRequest,
|
||||
NotesRequest,
|
||||
ExtendTimeRequest,
|
||||
PhaseInfo,
|
||||
TimerStatus,
|
||||
SuggestionItem,
|
||||
SessionResponse,
|
||||
SuggestionsResponse,
|
||||
PhasesListResponse,
|
||||
ActiveSessionsResponse,
|
||||
SessionHistoryItem,
|
||||
SessionHistoryResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
sessions,
|
||||
init_db_if_needed,
|
||||
persist_session,
|
||||
get_session_or_404,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
from ..websocket_manager import notify_phase_change, notify_session_ended
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Sessions"])
|
||||
|
||||
|
||||
def build_session_response(session: LessonSession) -> SessionResponse:
|
||||
"""Baut die vollstaendige Session-Response."""
|
||||
fsm = LessonStateMachine()
|
||||
timer = PhaseTimer()
|
||||
|
||||
timer_status = timer.get_phase_status(session)
|
||||
phases_info = fsm.get_phases_info(session)
|
||||
|
||||
return SessionResponse(
|
||||
session_id=session.session_id,
|
||||
teacher_id=session.teacher_id,
|
||||
class_id=session.class_id,
|
||||
subject=session.subject,
|
||||
topic=session.topic,
|
||||
current_phase=session.current_phase.value,
|
||||
phase_display_name=session.get_phase_display_name(),
|
||||
phase_started_at=session.phase_started_at.isoformat() if session.phase_started_at else None,
|
||||
lesson_started_at=session.lesson_started_at.isoformat() if session.lesson_started_at else None,
|
||||
lesson_ended_at=session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
|
||||
timer=TimerStatus(**timer_status),
|
||||
phases=[PhaseInfo(**p) for p in phases_info],
|
||||
phase_history=session.phase_history,
|
||||
notes=session.notes,
|
||||
homework=session.homework,
|
||||
is_active=fsm.is_lesson_active(session),
|
||||
is_ended=fsm.is_lesson_ended(session),
|
||||
is_paused=session.is_paused,
|
||||
)
|
||||
|
||||
|
||||
# === Session CRUD Endpoints ===
|
||||
|
||||
@router.post("/sessions", response_model=SessionResponse)
|
||||
async def create_session(request: CreateSessionRequest) -> SessionResponse:
|
||||
"""
|
||||
Erstellt eine neue Unterrichtsstunde (Session).
|
||||
|
||||
Die Stunde ist nach Erstellung im Status NOT_STARTED.
|
||||
Zum Starten muss /sessions/{id}/start aufgerufen werden.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# Default-Dauern mit uebergebenen Werten mergen
|
||||
phase_durations = {
|
||||
"einstieg": 8,
|
||||
"erarbeitung": 20,
|
||||
"sicherung": 10,
|
||||
"transfer": 7,
|
||||
"reflexion": 5,
|
||||
}
|
||||
if request.phase_durations:
|
||||
phase_durations.update(request.phase_durations)
|
||||
|
||||
session = LessonSession(
|
||||
session_id=str(uuid4()),
|
||||
teacher_id=request.teacher_id,
|
||||
class_id=request.class_id,
|
||||
subject=request.subject,
|
||||
topic=request.topic,
|
||||
phase_durations=phase_durations,
|
||||
)
|
||||
|
||||
sessions[session.session_id] = session
|
||||
persist_session(session)
|
||||
return build_session_response(session)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=SessionResponse)
|
||||
async def get_session(session_id: str) -> SessionResponse:
|
||||
"""
|
||||
Ruft den aktuellen Status einer Session ab.
|
||||
|
||||
Enthaelt alle Informationen inkl. Timer-Status und Phasen-Timeline.
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
return build_session_response(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/start", response_model=SessionResponse)
|
||||
async def start_lesson(session_id: str) -> SessionResponse:
|
||||
"""
|
||||
Startet die Unterrichtsstunde.
|
||||
|
||||
Wechselt von NOT_STARTED zur ersten Phase (EINSTIEG).
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
|
||||
if session.current_phase != LessonPhase.NOT_STARTED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Stunde bereits gestartet (aktuelle Phase: {session.current_phase.value})"
|
||||
)
|
||||
|
||||
fsm = LessonStateMachine()
|
||||
session = fsm.transition(session, LessonPhase.EINSTIEG)
|
||||
|
||||
persist_session(session)
|
||||
return build_session_response(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/next-phase", response_model=SessionResponse)
|
||||
async def next_phase(session_id: str) -> SessionResponse:
|
||||
"""
|
||||
Wechselt zur naechsten Phase.
|
||||
|
||||
Wirft 400 wenn keine naechste Phase verfuegbar (z.B. bei ENDED).
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
|
||||
fsm = LessonStateMachine()
|
||||
next_p = fsm.next_phase(session.current_phase)
|
||||
|
||||
if not next_p:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Keine naechste Phase verfuegbar (aktuelle Phase: {session.current_phase.value})"
|
||||
)
|
||||
|
||||
session = fsm.transition(session, next_p)
|
||||
persist_session(session)
|
||||
|
||||
# WebSocket-Benachrichtigung
|
||||
response = build_session_response(session)
|
||||
await notify_phase_change(session_id, session.current_phase.value, {
|
||||
"phase_display_name": session.get_phase_display_name(),
|
||||
"is_ended": session.current_phase == LessonPhase.ENDED
|
||||
})
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/end", response_model=SessionResponse)
|
||||
async def end_lesson(session_id: str) -> SessionResponse:
|
||||
"""
|
||||
Beendet die Unterrichtsstunde sofort.
|
||||
|
||||
Kann von jeder aktiven Phase aus aufgerufen werden.
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
|
||||
if session.current_phase == LessonPhase.ENDED:
|
||||
raise HTTPException(status_code=400, detail="Stunde bereits beendet")
|
||||
|
||||
if session.current_phase == LessonPhase.NOT_STARTED:
|
||||
raise HTTPException(status_code=400, detail="Stunde noch nicht gestartet")
|
||||
|
||||
# Direkt zur Endphase springen (ueberspringt evtl. Phasen)
|
||||
fsm = LessonStateMachine()
|
||||
|
||||
# Phasen bis zum Ende durchlaufen
|
||||
while session.current_phase != LessonPhase.ENDED:
|
||||
next_p = fsm.next_phase(session.current_phase)
|
||||
if next_p:
|
||||
session = fsm.transition(session, next_p)
|
||||
else:
|
||||
break
|
||||
|
||||
persist_session(session)
|
||||
|
||||
# WebSocket-Benachrichtigung
|
||||
await notify_session_ended(session_id)
|
||||
return build_session_response(session)
|
||||
|
||||
|
||||
# === Quick Actions (Feature f26/f27/f28) ===
|
||||
|
||||
@router.post("/sessions/{session_id}/pause", response_model=SessionResponse)
|
||||
async def toggle_pause(session_id: str) -> SessionResponse:
|
||||
"""
|
||||
Pausiert oder setzt die laufende Stunde fort (Feature f27).
|
||||
|
||||
Toggle-Funktion: Wenn pausiert -> fortsetzen, wenn laufend -> pausieren.
|
||||
Die Pause-Zeit wird nicht auf die Phasendauer angerechnet.
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
|
||||
# Nur aktive Phasen koennen pausiert werden
|
||||
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Stunde ist nicht aktiv"
|
||||
)
|
||||
|
||||
if session.is_paused:
|
||||
# Fortsetzen: Pause-Zeit zur Gesamt-Pause addieren
|
||||
if session.pause_started_at:
|
||||
pause_duration = (datetime.utcnow() - session.pause_started_at).total_seconds()
|
||||
session.total_paused_seconds += int(pause_duration)
|
||||
|
||||
session.is_paused = False
|
||||
session.pause_started_at = None
|
||||
else:
|
||||
# Pausieren
|
||||
session.is_paused = True
|
||||
session.pause_started_at = datetime.utcnow()
|
||||
|
||||
persist_session(session)
|
||||
return build_session_response(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/extend", response_model=SessionResponse)
|
||||
async def extend_phase(session_id: str, request: ExtendTimeRequest) -> SessionResponse:
|
||||
"""
|
||||
Verlaengert die aktuelle Phase um zusaetzliche Minuten (Feature f28).
|
||||
|
||||
Nuetzlich wenn mehr Zeit benoetigt wird, z.B. fuer vertiefte Diskussionen.
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
|
||||
# Nur aktive Phasen koennen verlaengert werden
|
||||
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Stunde ist nicht aktiv"
|
||||
)
|
||||
|
||||
# Aktuelle Phasendauer erhoehen
|
||||
phase_id = session.current_phase.value
|
||||
current_duration = session.phase_durations.get(phase_id, 10)
|
||||
session.phase_durations[phase_id] = current_duration + request.minutes
|
||||
|
||||
persist_session(session)
|
||||
return build_session_response(session)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/timer", response_model=TimerStatus)
|
||||
async def get_timer(session_id: str) -> TimerStatus:
|
||||
"""
|
||||
Ruft den Timer-Status der aktuellen Phase ab.
|
||||
|
||||
Enthaelt verbleibende Zeit, Warnung und Overtime-Status.
|
||||
Sollte alle 5 Sekunden gepollt werden.
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
timer = PhaseTimer()
|
||||
status = timer.get_phase_status(session)
|
||||
return TimerStatus(**status)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/suggestions", response_model=SuggestionsResponse)
|
||||
async def get_suggestions(
|
||||
session_id: str,
|
||||
limit: int = Query(3, ge=1, le=10, description="Anzahl Vorschlaege")
|
||||
) -> SuggestionsResponse:
|
||||
"""
|
||||
Ruft phasenspezifische Aktivitaets-Vorschlaege ab.
|
||||
|
||||
Die Vorschlaege aendern sich je nach aktueller Phase.
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
engine = SuggestionEngine()
|
||||
response = engine.get_suggestions_response(session, limit)
|
||||
|
||||
return SuggestionsResponse(
|
||||
suggestions=[SuggestionItem(**s) for s in response["suggestions"]],
|
||||
current_phase=response["current_phase"],
|
||||
phase_display_name=response["phase_display_name"],
|
||||
total_available=response["total_available"],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}/notes", response_model=SessionResponse)
|
||||
async def update_notes(session_id: str, request: NotesRequest) -> SessionResponse:
|
||||
"""
|
||||
Aktualisiert Notizen und Hausaufgaben der Stunde.
|
||||
"""
|
||||
session = get_session_or_404(session_id)
|
||||
session.notes = request.notes
|
||||
session.homework = request.homework
|
||||
persist_session(session)
|
||||
return build_session_response(session)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(session_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Loescht eine Session.
|
||||
"""
|
||||
if session_id not in sessions:
|
||||
raise HTTPException(status_code=404, detail="Session nicht gefunden")
|
||||
|
||||
del sessions[session_id]
|
||||
|
||||
# Auch aus DB loeschen
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from ..services.persistence import delete_session_from_db
|
||||
delete_session_from_db(session_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete session {session_id} from DB: {e}")
|
||||
|
||||
return {"status": "deleted", "session_id": session_id}
|
||||
|
||||
|
||||
# === Session History (Feature f17) ===
|
||||
|
||||
@router.get("/history/{teacher_id}", response_model=SessionHistoryResponse)
|
||||
async def get_session_history(
|
||||
teacher_id: str,
|
||||
limit: int = Query(20, ge=1, le=100, description="Max. Anzahl Eintraege"),
|
||||
offset: int = Query(0, ge=0, description="Offset fuer Pagination")
|
||||
) -> SessionHistoryResponse:
|
||||
"""
|
||||
Ruft die Session-History eines Lehrers ab (Feature f17).
|
||||
|
||||
Zeigt abgeschlossene Unterrichtsstunden mit Statistiken.
|
||||
Nur verfuegbar wenn DB aktiviert ist.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
if not DB_ENABLED:
|
||||
# Fallback: In-Memory Sessions filtern
|
||||
ended_sessions = [
|
||||
s for s in sessions.values()
|
||||
if s.teacher_id == teacher_id and s.current_phase == LessonPhase.ENDED
|
||||
]
|
||||
ended_sessions.sort(
|
||||
key=lambda x: x.lesson_ended_at or datetime.min,
|
||||
reverse=True
|
||||
)
|
||||
paginated = ended_sessions[offset:offset + limit]
|
||||
|
||||
items = []
|
||||
for s in paginated:
|
||||
duration = None
|
||||
if s.lesson_started_at and s.lesson_ended_at:
|
||||
duration = int((s.lesson_ended_at - s.lesson_started_at).total_seconds() / 60)
|
||||
|
||||
items.append(SessionHistoryItem(
|
||||
session_id=s.session_id,
|
||||
teacher_id=s.teacher_id,
|
||||
class_id=s.class_id,
|
||||
subject=s.subject,
|
||||
topic=s.topic,
|
||||
lesson_started_at=s.lesson_started_at.isoformat() if s.lesson_started_at else None,
|
||||
lesson_ended_at=s.lesson_ended_at.isoformat() if s.lesson_ended_at else None,
|
||||
total_duration_minutes=duration,
|
||||
phases_completed=len(s.phase_history),
|
||||
notes=s.notes,
|
||||
homework=s.homework,
|
||||
))
|
||||
|
||||
return SessionHistoryResponse(
|
||||
sessions=items,
|
||||
total_count=len(ended_sessions),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# DB-basierte History
|
||||
try:
|
||||
from classroom_engine.repository import SessionRepository
|
||||
db = SessionLocal()
|
||||
repo = SessionRepository(db)
|
||||
|
||||
# Beendete Sessions abrufen
|
||||
db_sessions = repo.get_history_by_teacher(teacher_id, limit, offset)
|
||||
|
||||
# Gesamtanzahl ermitteln
|
||||
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
|
||||
total_count = db.query(LessonSessionDB).filter(
|
||||
LessonSessionDB.teacher_id == teacher_id,
|
||||
LessonSessionDB.current_phase == LessonPhaseEnum.ENDED
|
||||
).count()
|
||||
|
||||
items = []
|
||||
for db_session in db_sessions:
|
||||
duration = None
|
||||
if db_session.lesson_started_at and db_session.lesson_ended_at:
|
||||
duration = int((db_session.lesson_ended_at - db_session.lesson_started_at).total_seconds() / 60)
|
||||
|
||||
phase_history = db_session.phase_history or []
|
||||
|
||||
items.append(SessionHistoryItem(
|
||||
session_id=db_session.id,
|
||||
teacher_id=db_session.teacher_id,
|
||||
class_id=db_session.class_id,
|
||||
subject=db_session.subject,
|
||||
topic=db_session.topic,
|
||||
lesson_started_at=db_session.lesson_started_at.isoformat() if db_session.lesson_started_at else None,
|
||||
lesson_ended_at=db_session.lesson_ended_at.isoformat() if db_session.lesson_ended_at else None,
|
||||
total_duration_minutes=duration,
|
||||
phases_completed=len(phase_history),
|
||||
notes=db_session.notes or "",
|
||||
homework=db_session.homework or "",
|
||||
))
|
||||
|
||||
db.close()
|
||||
|
||||
return SessionHistoryResponse(
|
||||
sessions=items,
|
||||
total_count=total_count,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get session history: {e}")
|
||||
raise HTTPException(status_code=500, detail="Fehler beim Laden der History")
|
||||
|
||||
|
||||
# === Utility Endpoints ===
|
||||
|
||||
@router.get("/phases", response_model=PhasesListResponse)
|
||||
async def list_phases() -> PhasesListResponse:
|
||||
"""
|
||||
Listet alle verfuegbaren Unterrichtsphasen mit Metadaten.
|
||||
"""
|
||||
phases = []
|
||||
for phase_id, config in LESSON_PHASES.items():
|
||||
phases.append({
|
||||
"phase": phase_id,
|
||||
"display_name": config["display_name"],
|
||||
"default_duration_minutes": config["default_duration_minutes"],
|
||||
"activities": config["activities"],
|
||||
"icon": config["icon"],
|
||||
"description": config.get("description", ""),
|
||||
})
|
||||
return PhasesListResponse(phases=phases)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=ActiveSessionsResponse)
|
||||
async def list_active_sessions(
|
||||
teacher_id: Optional[str] = Query(None, description="Filter nach Lehrer")
|
||||
) -> ActiveSessionsResponse:
|
||||
"""
|
||||
Listet alle (optionally gefilterten) Sessions.
|
||||
"""
|
||||
sessions_list = []
|
||||
for session in sessions.values():
|
||||
if teacher_id and session.teacher_id != teacher_id:
|
||||
continue
|
||||
|
||||
fsm = LessonStateMachine()
|
||||
sessions_list.append({
|
||||
"session_id": session.session_id,
|
||||
"teacher_id": session.teacher_id,
|
||||
"class_id": session.class_id,
|
||||
"subject": session.subject,
|
||||
"current_phase": session.current_phase.value,
|
||||
"is_active": fsm.is_lesson_active(session),
|
||||
"lesson_started_at": session.lesson_started_at.isoformat() if session.lesson_started_at else None,
|
||||
})
|
||||
|
||||
return ActiveSessionsResponse(
|
||||
sessions=sessions_list,
|
||||
count=len(sessions_list)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""
|
||||
Health-Check fuer den Classroom Service.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
db_status = "disabled"
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
db = SessionLocal()
|
||||
db.execute(text("SELECT 1"))
|
||||
db.close()
|
||||
db_status = "connected"
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "classroom-engine",
|
||||
"active_sessions": len(sessions),
|
||||
"db_enabled": DB_ENABLED,
|
||||
"db_status": db_status,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
184
backend/classroom/routes/settings.py
Normal file
184
backend/classroom/routes/settings.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Classroom API - Settings Routes
|
||||
|
||||
Teacher settings endpoints (Feature f16).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
|
||||
from classroom_engine import get_default_durations
|
||||
|
||||
from ..models import (
|
||||
TeacherSettingsResponse,
|
||||
UpdatePhaseDurationsRequest,
|
||||
UpdatePreferencesRequest,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Settings"])
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Database session dependency."""
|
||||
if DB_ENABLED and SessionLocal:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
yield None
|
||||
|
||||
|
||||
@router.get("/settings/{teacher_id}", response_model=TeacherSettingsResponse)
|
||||
async def get_teacher_settings(
|
||||
teacher_id: str,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Holt die Einstellungen eines Lehrers.
|
||||
|
||||
Gibt die personalisierten Phasen-Dauern und UI-Praeferenzen zurueck.
|
||||
Falls keine Einstellungen existieren, werden Defaults erstellt.
|
||||
|
||||
Args:
|
||||
teacher_id: ID des Lehrers
|
||||
|
||||
Returns:
|
||||
TeacherSettingsResponse mit allen Einstellungen
|
||||
"""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherSettingsRepository
|
||||
repo = TeacherSettingsRepository(db)
|
||||
settings = repo.get_or_create(teacher_id)
|
||||
return TeacherSettingsResponse(
|
||||
teacher_id=settings.teacher_id,
|
||||
default_phase_durations=settings.default_phase_durations or get_default_durations(),
|
||||
audio_enabled=settings.audio_enabled if settings.audio_enabled is not None else True,
|
||||
high_contrast=settings.high_contrast if settings.high_contrast is not None else False,
|
||||
show_statistics=settings.show_statistics if settings.show_statistics is not None else True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get teacher settings: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Laden der Einstellungen: {e}")
|
||||
|
||||
# Fallback: Defaults
|
||||
return TeacherSettingsResponse(
|
||||
teacher_id=teacher_id,
|
||||
default_phase_durations=get_default_durations(),
|
||||
audio_enabled=True,
|
||||
high_contrast=False,
|
||||
show_statistics=True,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings/{teacher_id}/durations", response_model=TeacherSettingsResponse)
|
||||
async def update_phase_durations(
|
||||
teacher_id: str,
|
||||
request: UpdatePhaseDurationsRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Aktualisiert die Standard-Phasendauern eines Lehrers.
|
||||
|
||||
Ermoeglicht Lehrern, ihre bevorzugten Phasen-Dauern zu speichern.
|
||||
Diese werden dann bei neuen Sessions als Default verwendet.
|
||||
|
||||
Args:
|
||||
teacher_id: ID des Lehrers
|
||||
request: Neue Phasen-Dauern in Minuten
|
||||
|
||||
Returns:
|
||||
Aktualisierte TeacherSettingsResponse
|
||||
"""
|
||||
# Validierung: Nur gueltige Phasen erlauben
|
||||
valid_phases = {"einstieg", "erarbeitung", "sicherung", "transfer", "reflexion"}
|
||||
for phase in request.durations:
|
||||
if phase not in valid_phases:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungueltige Phase: {phase}. Erlaubt: {valid_phases}"
|
||||
)
|
||||
if request.durations[phase] < 1 or request.durations[phase] > 120:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Phasen-Dauer muss zwischen 1 und 120 Minuten liegen"
|
||||
)
|
||||
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherSettingsRepository
|
||||
repo = TeacherSettingsRepository(db)
|
||||
settings = repo.update_phase_durations(teacher_id, request.durations)
|
||||
return TeacherSettingsResponse(
|
||||
teacher_id=settings.teacher_id,
|
||||
default_phase_durations=settings.default_phase_durations,
|
||||
audio_enabled=settings.audio_enabled if settings.audio_enabled is not None else True,
|
||||
high_contrast=settings.high_contrast if settings.high_contrast is not None else False,
|
||||
show_statistics=settings.show_statistics if settings.show_statistics is not None else True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update phase durations: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {e}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Datenbank nicht verfuegbar - Einstellungen koennen nicht gespeichert werden"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings/{teacher_id}/preferences", response_model=TeacherSettingsResponse)
|
||||
async def update_preferences(
|
||||
teacher_id: str,
|
||||
request: UpdatePreferencesRequest,
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Aktualisiert die UI-Praeferenzen eines Lehrers.
|
||||
|
||||
Ermoeglicht das Speichern von:
|
||||
- audio_enabled: Audio-Hinweise aktiviert
|
||||
- high_contrast: Hoher Kontrast fuer Beamer
|
||||
- show_statistics: Statistiken nach Stundenende anzeigen
|
||||
|
||||
Args:
|
||||
teacher_id: ID des Lehrers
|
||||
request: Zu aktualisierende Praeferenzen
|
||||
|
||||
Returns:
|
||||
Aktualisierte TeacherSettingsResponse
|
||||
"""
|
||||
if DB_ENABLED and db:
|
||||
try:
|
||||
from classroom_engine.repository import TeacherSettingsRepository
|
||||
repo = TeacherSettingsRepository(db)
|
||||
settings = repo.update_preferences(
|
||||
teacher_id=teacher_id,
|
||||
audio_enabled=request.audio_enabled,
|
||||
high_contrast=request.high_contrast,
|
||||
show_statistics=request.show_statistics
|
||||
)
|
||||
return TeacherSettingsResponse(
|
||||
teacher_id=settings.teacher_id,
|
||||
default_phase_durations=settings.default_phase_durations or get_default_durations(),
|
||||
audio_enabled=settings.audio_enabled if settings.audio_enabled is not None else True,
|
||||
high_contrast=settings.high_contrast if settings.high_contrast is not None else False,
|
||||
show_statistics=settings.show_statistics if settings.show_statistics is not None else True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update preferences: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {e}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Datenbank nicht verfuegbar - Einstellungen koennen nicht gespeichert werden"
|
||||
)
|
||||
382
backend/classroom/routes/templates.py
Normal file
382
backend/classroom/routes/templates.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Classroom API - Template Routes
|
||||
|
||||
Lesson template management endpoints (Feature f37).
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from classroom_engine import (
|
||||
LessonSession,
|
||||
LessonTemplate,
|
||||
SYSTEM_TEMPLATES,
|
||||
get_default_durations,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
TemplateCreate,
|
||||
TemplateUpdate,
|
||||
TemplateResponse,
|
||||
TemplateListResponse,
|
||||
)
|
||||
from ..services.persistence import (
|
||||
sessions,
|
||||
init_db_if_needed,
|
||||
persist_session,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
from .sessions import build_session_response, SessionResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Templates"])
|
||||
|
||||
|
||||
def _build_template_response(template: LessonTemplate, is_system: bool = False) -> TemplateResponse:
|
||||
"""Baut eine Template-Response."""
|
||||
return TemplateResponse(
|
||||
template_id=template.template_id,
|
||||
teacher_id=template.teacher_id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
subject=template.subject,
|
||||
grade_level=template.grade_level,
|
||||
phase_durations=template.phase_durations,
|
||||
default_topic=template.default_topic,
|
||||
default_notes=template.default_notes,
|
||||
is_public=template.is_public,
|
||||
usage_count=template.usage_count,
|
||||
total_duration_minutes=sum(template.phase_durations.values()),
|
||||
created_at=template.created_at.isoformat() if template.created_at else None,
|
||||
updated_at=template.updated_at.isoformat() if template.updated_at else None,
|
||||
is_system_template=is_system,
|
||||
)
|
||||
|
||||
|
||||
def _get_system_templates() -> List[TemplateResponse]:
|
||||
"""Gibt die vordefinierten System-Templates zurueck."""
|
||||
templates = []
|
||||
for t in SYSTEM_TEMPLATES:
|
||||
template = LessonTemplate(
|
||||
template_id=t["template_id"],
|
||||
teacher_id="system",
|
||||
name=t["name"],
|
||||
description=t.get("description", ""),
|
||||
phase_durations=t["phase_durations"],
|
||||
is_public=True,
|
||||
usage_count=0,
|
||||
)
|
||||
templates.append(_build_template_response(template, is_system=True))
|
||||
return templates
|
||||
|
||||
|
||||
@router.get("/templates", response_model=TemplateListResponse)
|
||||
async def list_templates(
|
||||
teacher_id: Optional[str] = Query(None, description="Filter nach Lehrer"),
|
||||
subject: Optional[str] = Query(None, description="Filter nach Fach"),
|
||||
include_system: bool = Query(True, description="System-Vorlagen einbeziehen")
|
||||
) -> TemplateListResponse:
|
||||
"""
|
||||
Listet verfuegbare Stunden-Vorlagen (Feature f37).
|
||||
|
||||
Ohne teacher_id werden nur oeffentliche und System-Vorlagen gezeigt.
|
||||
Mit teacher_id werden auch private Vorlagen des Lehrers angezeigt.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
templates: List[TemplateResponse] = []
|
||||
|
||||
# System-Templates hinzufuegen
|
||||
if include_system:
|
||||
system_templates = _get_system_templates()
|
||||
templates.extend(system_templates)
|
||||
|
||||
# DB-Templates laden wenn verfuegbar
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TemplateRepository
|
||||
db = SessionLocal()
|
||||
repo = TemplateRepository(db)
|
||||
|
||||
if subject:
|
||||
db_templates = repo.get_by_subject(subject, teacher_id)
|
||||
elif teacher_id:
|
||||
db_templates = repo.get_by_teacher(teacher_id, include_public=True)
|
||||
else:
|
||||
db_templates = repo.get_public_templates()
|
||||
|
||||
for db_t in db_templates:
|
||||
template = repo.to_dataclass(db_t)
|
||||
templates.append(_build_template_response(template))
|
||||
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load templates from DB: {e}")
|
||||
|
||||
return TemplateListResponse(
|
||||
templates=templates,
|
||||
total_count=len(templates)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=TemplateResponse)
|
||||
async def get_template(template_id: str) -> TemplateResponse:
|
||||
"""
|
||||
Ruft eine einzelne Vorlage ab.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# System-Template pruefen
|
||||
for t in SYSTEM_TEMPLATES:
|
||||
if t["template_id"] == template_id:
|
||||
template = LessonTemplate(
|
||||
template_id=t["template_id"],
|
||||
teacher_id="system",
|
||||
name=t["name"],
|
||||
description=t.get("description", ""),
|
||||
phase_durations=t["phase_durations"],
|
||||
is_public=True,
|
||||
)
|
||||
return _build_template_response(template, is_system=True)
|
||||
|
||||
# DB-Template pruefen
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TemplateRepository
|
||||
db = SessionLocal()
|
||||
repo = TemplateRepository(db)
|
||||
db_template = repo.get_by_id(template_id)
|
||||
if db_template:
|
||||
template = repo.to_dataclass(db_template)
|
||||
db.close()
|
||||
return _build_template_response(template)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get template {template_id}: {e}")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
|
||||
|
||||
|
||||
@router.post("/templates", response_model=TemplateResponse, status_code=201)
|
||||
async def create_template(
|
||||
request: TemplateCreate,
|
||||
teacher_id: str = Query(..., description="ID des erstellenden Lehrers")
|
||||
) -> TemplateResponse:
|
||||
"""
|
||||
Erstellt eine neue Stunden-Vorlage.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Datenbank nicht verfuegbar - Vorlagen koennen nicht gespeichert werden"
|
||||
)
|
||||
|
||||
phase_durations = request.phase_durations or get_default_durations()
|
||||
|
||||
template = LessonTemplate(
|
||||
template_id=str(uuid4()),
|
||||
teacher_id=teacher_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
subject=request.subject,
|
||||
grade_level=request.grade_level,
|
||||
phase_durations=phase_durations,
|
||||
default_topic=request.default_topic,
|
||||
default_notes=request.default_notes,
|
||||
is_public=request.is_public,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TemplateRepository
|
||||
db = SessionLocal()
|
||||
repo = TemplateRepository(db)
|
||||
db_template = repo.create(template)
|
||||
template = repo.to_dataclass(db_template)
|
||||
db.close()
|
||||
return _build_template_response(template)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create template: {e}")
|
||||
raise HTTPException(status_code=500, detail="Fehler beim Erstellen der Vorlage")
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}", response_model=TemplateResponse)
|
||||
async def update_template(
|
||||
template_id: str,
|
||||
request: TemplateUpdate,
|
||||
teacher_id: str = Query(..., description="ID des Lehrers (zur Berechtigung)")
|
||||
) -> TemplateResponse:
|
||||
"""
|
||||
Aktualisiert eine Stunden-Vorlage.
|
||||
|
||||
Nur der Ersteller kann die Vorlage bearbeiten.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# System-Templates koennen nicht bearbeitet werden
|
||||
for t in SYSTEM_TEMPLATES:
|
||||
if t["template_id"] == template_id:
|
||||
raise HTTPException(status_code=403, detail="System-Vorlagen koennen nicht bearbeitet werden")
|
||||
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TemplateRepository
|
||||
db = SessionLocal()
|
||||
repo = TemplateRepository(db)
|
||||
|
||||
db_template = repo.get_by_id(template_id)
|
||||
if not db_template:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
|
||||
|
||||
if db_template.teacher_id != teacher_id:
|
||||
db.close()
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
# Nur uebergebene Felder aktualisieren
|
||||
template = repo.to_dataclass(db_template)
|
||||
if request.name is not None:
|
||||
template.name = request.name
|
||||
if request.description is not None:
|
||||
template.description = request.description
|
||||
if request.subject is not None:
|
||||
template.subject = request.subject
|
||||
if request.grade_level is not None:
|
||||
template.grade_level = request.grade_level
|
||||
if request.phase_durations is not None:
|
||||
template.phase_durations = request.phase_durations
|
||||
if request.default_topic is not None:
|
||||
template.default_topic = request.default_topic
|
||||
if request.default_notes is not None:
|
||||
template.default_notes = request.default_notes
|
||||
if request.is_public is not None:
|
||||
template.is_public = request.is_public
|
||||
|
||||
db_template = repo.update(template)
|
||||
template = repo.to_dataclass(db_template)
|
||||
db.close()
|
||||
return _build_template_response(template)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update template {template_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Fehler beim Aktualisieren der Vorlage")
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def delete_template(
|
||||
template_id: str,
|
||||
teacher_id: str = Query(..., description="ID des Lehrers (zur Berechtigung)")
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Loescht eine Stunden-Vorlage.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# System-Templates koennen nicht geloescht werden
|
||||
for t in SYSTEM_TEMPLATES:
|
||||
if t["template_id"] == template_id:
|
||||
raise HTTPException(status_code=403, detail="System-Vorlagen koennen nicht geloescht werden")
|
||||
|
||||
if not DB_ENABLED:
|
||||
raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar")
|
||||
|
||||
try:
|
||||
from classroom_engine.repository import TemplateRepository
|
||||
db = SessionLocal()
|
||||
repo = TemplateRepository(db)
|
||||
|
||||
db_template = repo.get_by_id(template_id)
|
||||
if not db_template:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
|
||||
|
||||
if db_template.teacher_id != teacher_id:
|
||||
db.close()
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
repo.delete(template_id)
|
||||
db.close()
|
||||
return {"status": "deleted", "template_id": template_id}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete template {template_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Fehler beim Loeschen der Vorlage")
|
||||
|
||||
|
||||
@router.post("/sessions/from-template", response_model=SessionResponse)
|
||||
async def create_session_from_template(
|
||||
template_id: str = Query(..., description="ID der Vorlage"),
|
||||
teacher_id: str = Query(..., description="ID des Lehrers"),
|
||||
class_id: str = Query(..., description="ID der Klasse"),
|
||||
topic: Optional[str] = Query(None, description="Optionales Thema (ueberschreibt Default)")
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Erstellt eine neue Session basierend auf einer Vorlage.
|
||||
|
||||
Erhoeht automatisch den Usage-Counter der Vorlage.
|
||||
"""
|
||||
init_db_if_needed()
|
||||
|
||||
# Template laden
|
||||
template_data = None
|
||||
is_system = False
|
||||
|
||||
# System-Template pruefen
|
||||
for t in SYSTEM_TEMPLATES:
|
||||
if t["template_id"] == template_id:
|
||||
template_data = t
|
||||
is_system = True
|
||||
break
|
||||
|
||||
# DB-Template pruefen
|
||||
if not template_data and DB_ENABLED:
|
||||
try:
|
||||
from classroom_engine.repository import TemplateRepository
|
||||
db = SessionLocal()
|
||||
repo = TemplateRepository(db)
|
||||
db_template = repo.get_by_id(template_id)
|
||||
if db_template:
|
||||
template_data = {
|
||||
"phase_durations": db_template.phase_durations or get_default_durations(),
|
||||
"subject": db_template.subject or "",
|
||||
"default_topic": db_template.default_topic or "",
|
||||
"default_notes": db_template.default_notes or "",
|
||||
}
|
||||
# Usage Counter erhoehen
|
||||
repo.increment_usage(template_id)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load template {template_id}: {e}")
|
||||
|
||||
if not template_data:
|
||||
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
|
||||
|
||||
# Session erstellen
|
||||
session = LessonSession(
|
||||
session_id=str(uuid4()),
|
||||
teacher_id=teacher_id,
|
||||
class_id=class_id,
|
||||
subject=template_data.get("subject", ""),
|
||||
topic=topic or template_data.get("default_topic", ""),
|
||||
phase_durations=template_data["phase_durations"],
|
||||
notes=template_data.get("default_notes", ""),
|
||||
)
|
||||
|
||||
sessions[session.session_id] = session
|
||||
persist_session(session)
|
||||
|
||||
return build_session_response(session)
|
||||
143
backend/classroom/routes/websocket_routes.py
Normal file
143
backend/classroom/routes/websocket_routes.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Classroom API - WebSocket Routes
|
||||
|
||||
Real-time WebSocket endpoints for timer updates.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ..services.persistence import (
|
||||
sessions,
|
||||
init_db_if_needed,
|
||||
DB_ENABLED,
|
||||
SessionLocal,
|
||||
)
|
||||
from ..websocket_manager import (
|
||||
ws_manager,
|
||||
start_timer_broadcast,
|
||||
build_timer_status,
|
||||
is_timer_broadcast_running,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["WebSocket"])
|
||||
|
||||
|
||||
@router.websocket("/ws/{session_id}")
|
||||
async def websocket_timer(websocket: WebSocket, session_id: str):
|
||||
"""
|
||||
WebSocket-Endpoint fuer Echtzeit-Timer-Updates.
|
||||
|
||||
Features:
|
||||
- Sub-Sekunden Timer-Updates (jede Sekunde)
|
||||
- Phasenwechsel-Benachrichtigungen
|
||||
- Session-Ende-Benachrichtigungen
|
||||
- Multi-Device Support
|
||||
|
||||
Protocol:
|
||||
- Server sendet JSON-Messages mit "type" und "data"
|
||||
- Types: "timer_update", "phase_change", "session_ended", "error", "connected"
|
||||
- Client kann "ping" senden fuer Keepalive
|
||||
"""
|
||||
# Session validieren bevor Connect
|
||||
session = sessions.get(session_id)
|
||||
if not session:
|
||||
# Versuche aus DB zu laden
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
init_db_if_needed()
|
||||
from classroom_engine.repository import SessionRepository
|
||||
db = SessionLocal()
|
||||
repo = SessionRepository(db)
|
||||
db_session = repo.get_by_id(session_id)
|
||||
if db_session:
|
||||
session = repo.to_dataclass(db_session)
|
||||
sessions[session_id] = session
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket: Failed to load session {session_id}: {e}")
|
||||
|
||||
if not session:
|
||||
await websocket.close(code=4004, reason="Session not found")
|
||||
return
|
||||
|
||||
if session.is_ended:
|
||||
await websocket.close(code=4001, reason="Session already ended")
|
||||
return
|
||||
|
||||
# Verbindung akzeptieren und registrieren
|
||||
await ws_manager.connect(websocket, session_id)
|
||||
|
||||
# Timer-Broadcast-Task starten wenn noetig
|
||||
start_timer_broadcast(sessions)
|
||||
|
||||
# Initiale Daten senden
|
||||
try:
|
||||
initial_timer = build_timer_status(session)
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"data": {
|
||||
"session_id": session_id,
|
||||
"client_count": ws_manager.get_client_count(session_id),
|
||||
"timer": initial_timer
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket: Failed to send initial data: {e}")
|
||||
|
||||
# Message-Loop
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
message = await websocket.receive_text()
|
||||
data = json.loads(message)
|
||||
|
||||
if data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
elif data.get("type") == "get_timer":
|
||||
# Client kann manuell Timer-Status anfordern
|
||||
session = sessions.get(session_id)
|
||||
if session and not session.is_ended:
|
||||
timer_data = build_timer_status(session)
|
||||
await websocket.send_json({
|
||||
"type": "timer_update",
|
||||
"data": timer_data
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"data": {"message": "Invalid JSON"}
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"WebSocket: Client disconnected from session {session_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error: {e}")
|
||||
finally:
|
||||
await ws_manager.disconnect(websocket)
|
||||
|
||||
|
||||
@router.get("/ws/status")
|
||||
async def websocket_status() -> Dict[str, Any]:
|
||||
"""
|
||||
Status-Endpoint fuer WebSocket-Verbindungen.
|
||||
|
||||
Zeigt aktive Sessions und Verbindungszahlen.
|
||||
"""
|
||||
active_sessions = ws_manager.get_active_sessions()
|
||||
return {
|
||||
"active_sessions": len(active_sessions),
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": sid,
|
||||
"client_count": ws_manager.get_client_count(sid)
|
||||
}
|
||||
for sid in active_sessions
|
||||
],
|
||||
"broadcast_task_running": is_timer_broadcast_running()
|
||||
}
|
||||
23
backend/classroom/services/__init__.py
Normal file
23
backend/classroom/services/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Classroom Services Package
|
||||
"""
|
||||
|
||||
from .persistence import (
|
||||
init_db_if_needed,
|
||||
load_active_sessions_from_db,
|
||||
persist_session,
|
||||
get_session_or_404,
|
||||
DB_ENABLED,
|
||||
sessions,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"init_db_if_needed",
|
||||
"load_active_sessions_from_db",
|
||||
"persist_session",
|
||||
"get_session_or_404",
|
||||
"DB_ENABLED",
|
||||
"sessions",
|
||||
"SessionLocal",
|
||||
]
|
||||
131
backend/classroom/services/persistence.py
Normal file
131
backend/classroom/services/persistence.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Classroom API - Database Persistence Service
|
||||
|
||||
Handles hybrid storage: In-Memory + PostgreSQL for sessions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from classroom_engine import LessonSession, LessonPhase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database imports (Feature f22)
|
||||
try:
|
||||
from classroom_engine.database import get_db, init_db, SessionLocal
|
||||
from classroom_engine.repository import SessionRepository
|
||||
DB_ENABLED = True
|
||||
except ImportError:
|
||||
DB_ENABLED = False
|
||||
SessionLocal = None
|
||||
logger.warning("Classroom DB not available, using in-memory storage only")
|
||||
|
||||
|
||||
# === Hybrid Storage: In-Memory + DB (Feature f22) ===
|
||||
# In-Memory fuer schnellen Zugriff, DB fuer Persistenz
|
||||
|
||||
sessions: Dict[str, LessonSession] = {}
|
||||
_db_initialized = False
|
||||
|
||||
|
||||
def init_db_if_needed():
|
||||
"""Initialisiert DB und laedt aktive Sessions beim ersten Aufruf."""
|
||||
global _db_initialized
|
||||
if _db_initialized or not DB_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
init_db()
|
||||
load_active_sessions_from_db()
|
||||
_db_initialized = True
|
||||
logger.info("Classroom DB initialized, loaded active sessions")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Classroom DB: {e}")
|
||||
|
||||
|
||||
def load_active_sessions_from_db():
|
||||
"""Laedt alle aktiven Sessions aus der DB in den Memory-Cache."""
|
||||
if not DB_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
db = SessionLocal()
|
||||
repo = SessionRepository(db)
|
||||
|
||||
# Lade alle nicht-beendeten Sessions
|
||||
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
|
||||
active_db_sessions = db.query(LessonSessionDB).filter(
|
||||
LessonSessionDB.current_phase != LessonPhaseEnum.ENDED
|
||||
).all()
|
||||
|
||||
for db_session in active_db_sessions:
|
||||
session = repo.to_dataclass(db_session)
|
||||
sessions[session.session_id] = session
|
||||
logger.info(f"Loaded session {session.session_id} from DB")
|
||||
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load sessions from DB: {e}")
|
||||
|
||||
|
||||
def persist_session(session: LessonSession):
|
||||
"""Speichert/aktualisiert Session in der DB."""
|
||||
if not DB_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
db = SessionLocal()
|
||||
repo = SessionRepository(db)
|
||||
|
||||
existing = repo.get_by_id(session.session_id)
|
||||
if existing:
|
||||
repo.update(session)
|
||||
else:
|
||||
repo.create(session)
|
||||
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist session {session.session_id}: {e}")
|
||||
|
||||
|
||||
def get_session_or_404(session_id: str) -> LessonSession:
|
||||
"""Holt eine Session oder wirft 404. Prueft auch DB bei Cache-Miss."""
|
||||
init_db_if_needed()
|
||||
|
||||
session = sessions.get(session_id)
|
||||
if session:
|
||||
return session
|
||||
|
||||
# Feature f22: Bei Cache-Miss aus DB laden
|
||||
if DB_ENABLED:
|
||||
try:
|
||||
db = SessionLocal()
|
||||
repo = SessionRepository(db)
|
||||
db_session = repo.get_by_id(session_id)
|
||||
if db_session:
|
||||
session = repo.to_dataclass(db_session)
|
||||
sessions[session.session_id] = session # In Cache laden
|
||||
db.close()
|
||||
return session
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load session {session_id} from DB: {e}")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Session nicht gefunden")
|
||||
|
||||
|
||||
def delete_session_from_db(session_id: str):
|
||||
"""Loescht Session aus der DB."""
|
||||
if not DB_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
db = SessionLocal()
|
||||
repo = SessionRepository(db)
|
||||
repo.delete(session_id)
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete session {session_id} from DB: {e}")
|
||||
204
backend/classroom/websocket_manager.py
Normal file
204
backend/classroom/websocket_manager.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Classroom API - WebSocket Connection Manager
|
||||
|
||||
Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""
|
||||
Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates.
|
||||
|
||||
Features:
|
||||
- Session-basierte Verbindungen (jede Session hat eigene Clients)
|
||||
- Automatisches Cleanup bei Disconnect
|
||||
- Broadcast an alle Clients einer Session
|
||||
- Multi-Device Support
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# session_id -> Set[WebSocket]
|
||||
self._connections: Dict[str, set] = {}
|
||||
# WebSocket -> session_id (reverse lookup)
|
||||
self._websocket_sessions: Dict[WebSocket, str] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket, session_id: str):
|
||||
"""Verbindet einen Client mit einer Session."""
|
||||
await websocket.accept()
|
||||
async with self._lock:
|
||||
if session_id not in self._connections:
|
||||
self._connections[session_id] = set()
|
||||
self._connections[session_id].add(websocket)
|
||||
self._websocket_sessions[websocket] = session_id
|
||||
logger.info(f"WebSocket connected to session {session_id}, total clients: {len(self._connections[session_id])}")
|
||||
|
||||
async def disconnect(self, websocket: WebSocket):
|
||||
"""Trennt einen Client."""
|
||||
async with self._lock:
|
||||
session_id = self._websocket_sessions.pop(websocket, None)
|
||||
if session_id and session_id in self._connections:
|
||||
self._connections[session_id].discard(websocket)
|
||||
if not self._connections[session_id]:
|
||||
del self._connections[session_id]
|
||||
logger.info(f"WebSocket disconnected from session {session_id}")
|
||||
|
||||
async def broadcast_to_session(self, session_id: str, message: dict):
|
||||
"""Sendet eine Nachricht an alle Clients einer Session."""
|
||||
async with self._lock:
|
||||
connections = self._connections.get(session_id, set()).copy()
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
message_json = json.dumps(message)
|
||||
dead_connections = []
|
||||
|
||||
for websocket in connections:
|
||||
try:
|
||||
await websocket.send_text(message_json)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send to websocket: {e}")
|
||||
dead_connections.append(websocket)
|
||||
|
||||
# Cleanup dead connections
|
||||
for ws in dead_connections:
|
||||
await self.disconnect(ws)
|
||||
|
||||
async def broadcast_timer_update(self, session_id: str, timer_data: dict):
|
||||
"""Sendet Timer-Update an alle Clients einer Session."""
|
||||
await self.broadcast_to_session(session_id, {
|
||||
"type": "timer_update",
|
||||
"data": timer_data
|
||||
})
|
||||
|
||||
async def broadcast_phase_change(self, session_id: str, phase_data: dict):
|
||||
"""Sendet Phasenwechsel-Event an alle Clients."""
|
||||
await self.broadcast_to_session(session_id, {
|
||||
"type": "phase_change",
|
||||
"data": phase_data
|
||||
})
|
||||
|
||||
async def broadcast_session_ended(self, session_id: str):
|
||||
"""Sendet Session-Ende-Event an alle Clients."""
|
||||
await self.broadcast_to_session(session_id, {
|
||||
"type": "session_ended",
|
||||
"data": {"session_id": session_id}
|
||||
})
|
||||
|
||||
def get_client_count(self, session_id: str) -> int:
|
||||
"""Gibt die Anzahl der verbundenen Clients fuer eine Session zurueck."""
|
||||
return len(self._connections.get(session_id, set()))
|
||||
|
||||
def get_active_sessions(self) -> List[str]:
|
||||
"""Gibt alle Sessions mit aktiven WebSocket-Verbindungen zurueck."""
|
||||
return list(self._connections.keys())
|
||||
|
||||
|
||||
# Global connection manager instance
|
||||
ws_manager = ConnectionManager()
|
||||
|
||||
# Background task handle
|
||||
_timer_broadcast_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
def build_timer_status(session) -> dict:
|
||||
"""
|
||||
Baut Timer-Status als dict fuer WebSocket-Broadcast.
|
||||
|
||||
Returns dict mit allen Timer-Feldern die der Client benoetigt.
|
||||
"""
|
||||
from classroom_engine import PhaseTimer
|
||||
|
||||
timer = PhaseTimer()
|
||||
status = timer.get_phase_status(session)
|
||||
|
||||
# Zusaetzliche Felder fuer WebSocket
|
||||
status["session_id"] = session.session_id
|
||||
status["current_phase"] = session.current_phase.value
|
||||
status["is_paused"] = session.is_paused
|
||||
status["timestamp"] = datetime.utcnow().isoformat()
|
||||
|
||||
return status
|
||||
|
||||
|
||||
async def timer_broadcast_loop(sessions_dict: Dict):
|
||||
"""
|
||||
Hintergrund-Task der Timer-Updates alle 1 Sekunde an verbundene Clients sendet.
|
||||
|
||||
Features:
|
||||
- Sub-Sekunden Genauigkeit (jede Sekunde)
|
||||
- Nur aktive Sessions werden aktualisiert
|
||||
- Automatisches Cleanup bei Fehlern
|
||||
"""
|
||||
logger.info("Timer broadcast loop started")
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
active_ws_sessions = ws_manager.get_active_sessions()
|
||||
if not active_ws_sessions:
|
||||
continue
|
||||
|
||||
for session_id in active_ws_sessions:
|
||||
session = sessions_dict.get(session_id)
|
||||
if not session or session.is_ended:
|
||||
continue
|
||||
|
||||
# Timer-Status berechnen
|
||||
timer_status = build_timer_status(session)
|
||||
|
||||
# An alle Clients senden
|
||||
await ws_manager.broadcast_timer_update(session_id, timer_status)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Timer broadcast loop cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in timer broadcast loop: {e}")
|
||||
await asyncio.sleep(5) # Kurze Pause bei Fehler
|
||||
|
||||
|
||||
def start_timer_broadcast(sessions_dict: Dict):
|
||||
"""Startet den Timer-Broadcast-Task wenn noch nicht laufend."""
|
||||
global _timer_broadcast_task
|
||||
if _timer_broadcast_task is None or _timer_broadcast_task.done():
|
||||
_timer_broadcast_task = asyncio.create_task(timer_broadcast_loop(sessions_dict))
|
||||
logger.info("Timer broadcast task created")
|
||||
|
||||
|
||||
def stop_timer_broadcast():
|
||||
"""Stoppt den Timer-Broadcast-Task."""
|
||||
global _timer_broadcast_task
|
||||
if _timer_broadcast_task and not _timer_broadcast_task.done():
|
||||
_timer_broadcast_task.cancel()
|
||||
logger.info("Timer broadcast task cancelled")
|
||||
|
||||
|
||||
def is_timer_broadcast_running() -> bool:
|
||||
"""Prueft ob der Timer-Broadcast-Task laeuft."""
|
||||
return _timer_broadcast_task is not None and not _timer_broadcast_task.done()
|
||||
|
||||
|
||||
# Broadcast bei Phasenwechsel und Session-Ende
|
||||
async def notify_phase_change(session_id: str, new_phase: str, phase_info: dict):
|
||||
"""Benachrichtigt alle verbundenen Clients ueber Phasenwechsel."""
|
||||
await ws_manager.broadcast_phase_change(session_id, {
|
||||
"new_phase": new_phase,
|
||||
"phase_info": phase_info,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
|
||||
async def notify_session_ended(session_id: str):
|
||||
"""Benachrichtigt alle verbundenen Clients ueber Session-Ende."""
|
||||
await ws_manager.broadcast_session_ended(session_id)
|
||||
Reference in New Issue
Block a user