[split-required] Split final 43 files (500-668 LOC) to complete refactoring

klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 09:41:42 +02:00
parent 451365a312
commit bd4b956e3c
113 changed files with 13790 additions and 14148 deletions

View File

@@ -2,567 +2,68 @@
Classroom API - Pydantic Models
Alle Request- und Response-Models fuer die Classroom API.
Barrel re-export aus aufgeteilten Modulen.
"""
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
# Session & Phase Models
from .models_session import (
CreateSessionRequest,
NotesRequest,
ExtendTimeRequest,
PhaseInfo,
TimerStatus,
SuggestionItem,
SessionResponse,
SuggestionsResponse,
PhasesListResponse,
ActiveSessionsResponse,
SessionHistoryItem,
SessionHistoryResponse,
)
# Template, Homework, Material Models
from .models_templates import (
TemplateCreate,
TemplateUpdate,
TemplateResponse,
TemplateListResponse,
CreateHomeworkRequest,
UpdateHomeworkRequest,
HomeworkResponse,
HomeworkListResponse,
CreateMaterialRequest,
UpdateMaterialRequest,
MaterialResponse,
MaterialListResponse,
)
# Analytics, Reflection, Feedback, Settings Models
from .models_analytics import (
SessionSummaryResponse,
TeacherAnalyticsResponse,
ReflectionCreate,
ReflectionUpdate,
ReflectionResponse,
FeedbackCreate,
FeedbackResponse,
FeedbackListResponse,
FeedbackStatsResponse,
TeacherSettingsResponse,
UpdatePhaseDurationsRequest,
UpdatePreferencesRequest,
)
# Context, Event, Routine Models
from .models_context import (
SchoolInfo,
SchoolYearInfo,
MacroPhaseInfo,
CoreCounts,
ContextFlags,
TeacherContextResponse,
UpdateContextRequest,
CreateEventRequest,
EventResponse,
CreateRoutineRequest,
RoutineResponse,
)

View File

@@ -0,0 +1,161 @@
"""
Classroom API - Analytics, Reflection, Feedback, Settings Pydantic Models.
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
# === 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

View File

@@ -0,0 +1,128 @@
"""
Classroom API - Context, Event, Routine Pydantic Models.
"""
from typing import Optional
from pydantic import BaseModel, Field
# === Context Models ===
class SchoolInfo(BaseModel):
"""Schul-Informationen."""
federal_state: str
federal_state_name: str = ""
school_type: str
school_type_name: str = ""
class SchoolYearInfo(BaseModel):
"""Schuljahr-Informationen."""
id: str
start: Optional[str] = None
current_week: int = 1
class MacroPhaseInfo(BaseModel):
"""Makro-Phase Informationen."""
id: str
label: str
confidence: float = 1.0
class CoreCounts(BaseModel):
"""Kern-Zaehler fuer den Kontext."""
classes: int = 0
exams_scheduled: int = 0
corrections_pending: int = 0
class ContextFlags(BaseModel):
"""Status-Flags des Kontexts."""
onboarding_completed: bool = False
has_classes: bool = False
has_schedule: bool = False
is_exam_period: bool = False
is_before_holidays: bool = False
class TeacherContextResponse(BaseModel):
"""Response fuer GET /v1/context."""
schema_version: str = "1.0"
teacher_id: str
school: SchoolInfo
school_year: SchoolYearInfo
macro_phase: MacroPhaseInfo
core_counts: CoreCounts
flags: ContextFlags
class UpdateContextRequest(BaseModel):
"""Request zum Aktualisieren des Kontexts."""
federal_state: Optional[str] = None
school_type: Optional[str] = None
schoolyear: Optional[str] = None
schoolyear_start: Optional[str] = None
macro_phase: Optional[str] = None
current_week: Optional[int] = None
# === Event Models ===
class CreateEventRequest(BaseModel):
"""Request zum Erstellen eines Events."""
title: str
event_type: str = "other"
start_date: str
end_date: Optional[str] = None
class_id: Optional[str] = None
subject: Optional[str] = None
description: str = ""
needs_preparation: bool = True
reminder_days_before: int = 7
class EventResponse(BaseModel):
"""Response fuer ein Event."""
id: str
teacher_id: str
event_type: str
title: str
description: str
start_date: str
end_date: Optional[str]
class_id: Optional[str]
subject: Optional[str]
status: str
needs_preparation: bool
preparation_done: bool
reminder_days_before: int
# === Routine Models ===
class CreateRoutineRequest(BaseModel):
"""Request zum Erstellen einer Routine."""
title: str
routine_type: str = "other"
recurrence_pattern: str = "weekly"
day_of_week: Optional[int] = None
day_of_month: Optional[int] = None
time_of_day: Optional[str] = None
duration_minutes: int = 60
description: str = ""
class RoutineResponse(BaseModel):
"""Response fuer eine Routine."""
id: str
teacher_id: str
routine_type: str
title: str
description: str
recurrence_pattern: str
day_of_week: Optional[int]
day_of_month: Optional[int]
time_of_day: Optional[str]
duration_minutes: int
is_active: bool

View File

@@ -0,0 +1,137 @@
"""
Classroom API - Session & Phase Pydantic Models.
"""
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

View File

@@ -0,0 +1,158 @@
"""
Classroom API - Template, Homework, Material Pydantic Models.
"""
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
# === 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

View File

@@ -1,525 +1,17 @@
"""
Classroom API - Session Routes
Classroom API - Session Routes (barrel re-export)
Session management endpoints: create, get, start, next-phase, end, etc.
Combines core session routes and action routes into a single router.
"""
from uuid import uuid4
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Query
from .sessions_core import router as core_router, build_session_response
from .sessions_actions import router as actions_router
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
SuggestionEngine,
LESSON_PHASES,
)
router = APIRouter()
router.include_router(core_router)
router.include_router(actions_router)
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(),
}
# Re-export for backward compatibility
__all__ = ["router", "build_session_response"]

View File

@@ -0,0 +1,173 @@
"""
Classroom API - Session Actions Routes
Quick actions (pause, extend, timer), suggestions, utility endpoints.
"""
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text
from classroom_engine import (
LessonPhase,
LessonStateMachine,
PhaseTimer,
SuggestionEngine,
LESSON_PHASES,
)
from ..models import (
ExtendTimeRequest,
TimerStatus,
SuggestionItem,
SuggestionsResponse,
PhasesListResponse,
ActiveSessionsResponse,
)
from ..services.persistence import (
sessions,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from .sessions_core import build_session_response, SessionResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
# === 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)."""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
if session.is_paused:
if session.pause_started_at:
pause_duration = (datetime.utcnow() - session.pause_started_at).total_seconds()
session.total_paused_seconds += int(pause_duration)
session.is_paused = False
session.pause_started_at = None
else:
session.is_paused = True
session.pause_started_at = datetime.utcnow()
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/extend", response_model=SessionResponse)
async def extend_phase(session_id: str, request: ExtendTimeRequest) -> SessionResponse:
"""Verlaengert die aktuelle Phase um zusaetzliche Minuten (Feature f28)."""
session = get_session_or_404(session_id)
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
raise HTTPException(status_code=400, detail="Stunde ist nicht aktiv")
phase_id = session.current_phase.value
current_duration = session.phase_durations.get(phase_id, 10)
session.phase_durations[phase_id] = current_duration + request.minutes
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}/timer", response_model=TimerStatus)
async def get_timer(session_id: str) -> TimerStatus:
"""Ruft den Timer-Status der aktuellen Phase ab."""
session = get_session_or_404(session_id)
timer = PhaseTimer()
status = timer.get_phase_status(session)
return TimerStatus(**status)
@router.get("/sessions/{session_id}/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(
session_id: str,
limit: int = Query(3, ge=1, le=10)
) -> SuggestionsResponse:
"""Ruft phasenspezifische Aktivitaets-Vorschlaege ab."""
session = get_session_or_404(session_id)
engine = SuggestionEngine()
response = engine.get_suggestions_response(session, limit)
return SuggestionsResponse(
suggestions=[SuggestionItem(**s) for s in response["suggestions"]],
current_phase=response["current_phase"],
phase_display_name=response["phase_display_name"],
total_available=response["total_available"],
)
# === 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)
) -> 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."""
db_status = "disabled"
if DB_ENABLED:
try:
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy",
"service": "classroom-engine",
"active_sessions": len(sessions),
"db_enabled": DB_ENABLED,
"db_status": db_status,
"timestamp": datetime.utcnow().isoformat(),
}

View File

@@ -0,0 +1,283 @@
"""
Classroom API - Session Core Routes
Session CRUD, lifecycle, and history endpoints.
"""
from uuid import uuid4
from typing import Dict, Optional, Any
from datetime import datetime
import logging
from fastapi import APIRouter, HTTPException, Query
from classroom_engine import (
LessonPhase,
LessonSession,
LessonStateMachine,
PhaseTimer,
LESSON_PHASES,
)
from ..models import (
CreateSessionRequest,
NotesRequest,
PhaseInfo,
TimerStatus,
SessionResponse,
PhasesListResponse,
ActiveSessionsResponse,
SessionHistoryItem,
SessionHistoryResponse,
)
from ..services.persistence import (
sessions,
init_db_if_needed,
persist_session,
get_session_or_404,
DB_ENABLED,
SessionLocal,
)
from ..websocket_manager import notify_phase_change, notify_session_ended
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Sessions"])
def build_session_response(session: LessonSession) -> SessionResponse:
"""Baut die vollstaendige Session-Response."""
fsm = LessonStateMachine()
timer = PhaseTimer()
timer_status = timer.get_phase_status(session)
phases_info = fsm.get_phases_info(session)
return SessionResponse(
session_id=session.session_id,
teacher_id=session.teacher_id,
class_id=session.class_id,
subject=session.subject,
topic=session.topic,
current_phase=session.current_phase.value,
phase_display_name=session.get_phase_display_name(),
phase_started_at=session.phase_started_at.isoformat() if session.phase_started_at else None,
lesson_started_at=session.lesson_started_at.isoformat() if session.lesson_started_at else None,
lesson_ended_at=session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
timer=TimerStatus(**timer_status),
phases=[PhaseInfo(**p) for p in phases_info],
phase_history=session.phase_history,
notes=session.notes,
homework=session.homework,
is_active=fsm.is_lesson_active(session),
is_ended=fsm.is_lesson_ended(session),
is_paused=session.is_paused,
)
# === Session CRUD Endpoints ===
@router.post("/sessions", response_model=SessionResponse)
async def create_session(request: CreateSessionRequest) -> SessionResponse:
"""Erstellt eine neue Unterrichtsstunde (Session)."""
init_db_if_needed()
phase_durations = {
"einstieg": 8, "erarbeitung": 20, "sicherung": 10,
"transfer": 7, "reflexion": 5,
}
if request.phase_durations:
phase_durations.update(request.phase_durations)
session = LessonSession(
session_id=str(uuid4()),
teacher_id=request.teacher_id,
class_id=request.class_id,
subject=request.subject,
topic=request.topic,
phase_durations=phase_durations,
)
sessions[session.session_id] = session
persist_session(session)
return build_session_response(session)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(session_id: str) -> SessionResponse:
"""Ruft den aktuellen Status einer Session ab."""
session = get_session_or_404(session_id)
return build_session_response(session)
@router.post("/sessions/{session_id}/start", response_model=SessionResponse)
async def start_lesson(session_id: str) -> SessionResponse:
"""Startet die Unterrichtsstunde."""
session = get_session_or_404(session_id)
if session.current_phase != LessonPhase.NOT_STARTED:
raise HTTPException(
status_code=400,
detail=f"Stunde bereits gestartet (aktuelle Phase: {session.current_phase.value})"
)
fsm = LessonStateMachine()
session = fsm.transition(session, LessonPhase.EINSTIEG)
persist_session(session)
return build_session_response(session)
@router.post("/sessions/{session_id}/next-phase", response_model=SessionResponse)
async def next_phase(session_id: str) -> SessionResponse:
"""Wechselt zur naechsten Phase."""
session = get_session_or_404(session_id)
fsm = LessonStateMachine()
next_p = fsm.next_phase(session.current_phase)
if not next_p:
raise HTTPException(
status_code=400,
detail=f"Keine naechste Phase verfuegbar (aktuelle Phase: {session.current_phase.value})"
)
session = fsm.transition(session, next_p)
persist_session(session)
response = build_session_response(session)
await notify_phase_change(session_id, session.current_phase.value, {
"phase_display_name": session.get_phase_display_name(),
"is_ended": session.current_phase == LessonPhase.ENDED
})
return response
@router.post("/sessions/{session_id}/end", response_model=SessionResponse)
async def end_lesson(session_id: str) -> SessionResponse:
"""Beendet die Unterrichtsstunde sofort."""
session = get_session_or_404(session_id)
if session.current_phase == LessonPhase.ENDED:
raise HTTPException(status_code=400, detail="Stunde bereits beendet")
if session.current_phase == LessonPhase.NOT_STARTED:
raise HTTPException(status_code=400, detail="Stunde noch nicht gestartet")
fsm = LessonStateMachine()
while session.current_phase != LessonPhase.ENDED:
next_p = fsm.next_phase(session.current_phase)
if next_p:
session = fsm.transition(session, next_p)
else:
break
persist_session(session)
await notify_session_ended(session_id)
return build_session_response(session)
@router.put("/sessions/{session_id}/notes", response_model=SessionResponse)
async def update_notes(session_id: str, request: NotesRequest) -> SessionResponse:
"""Aktualisiert Notizen und Hausaufgaben der Stunde."""
session = get_session_or_404(session_id)
session.notes = request.notes
session.homework = request.homework
persist_session(session)
return build_session_response(session)
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> Dict[str, str]:
"""Loescht eine Session."""
if session_id not in sessions:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
del sessions[session_id]
if DB_ENABLED:
try:
from ..services.persistence import delete_session_from_db
delete_session_from_db(session_id)
except Exception as e:
logger.error(f"Failed to delete session {session_id} from DB: {e}")
return {"status": "deleted", "session_id": session_id}
# === Session History (Feature f17) ===
@router.get("/history/{teacher_id}", response_model=SessionHistoryResponse)
async def get_session_history(
teacher_id: str,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0)
) -> SessionHistoryResponse:
"""Ruft die Session-History eines Lehrers ab (Feature f17)."""
init_db_if_needed()
if not DB_ENABLED:
ended_sessions = [
s for s in sessions.values()
if s.teacher_id == teacher_id and s.current_phase == LessonPhase.ENDED
]
ended_sessions.sort(key=lambda x: x.lesson_ended_at or datetime.min, reverse=True)
paginated = ended_sessions[offset:offset + limit]
items = []
for s in paginated:
duration = None
if s.lesson_started_at and s.lesson_ended_at:
duration = int((s.lesson_ended_at - s.lesson_started_at).total_seconds() / 60)
items.append(SessionHistoryItem(
session_id=s.session_id, teacher_id=s.teacher_id,
class_id=s.class_id, subject=s.subject, topic=s.topic,
lesson_started_at=s.lesson_started_at.isoformat() if s.lesson_started_at else None,
lesson_ended_at=s.lesson_ended_at.isoformat() if s.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(s.phase_history),
notes=s.notes, homework=s.homework,
))
return SessionHistoryResponse(
sessions=items, total_count=len(ended_sessions), limit=limit, offset=offset,
)
try:
from classroom_engine.repository import SessionRepository
db = SessionLocal()
repo = SessionRepository(db)
db_sessions = repo.get_history_by_teacher(teacher_id, limit, offset)
from classroom_engine.db_models import LessonSessionDB, LessonPhaseEnum
total_count = db.query(LessonSessionDB).filter(
LessonSessionDB.teacher_id == teacher_id,
LessonSessionDB.current_phase == LessonPhaseEnum.ENDED
).count()
items = []
for db_session in db_sessions:
duration = None
if db_session.lesson_started_at and db_session.lesson_ended_at:
duration = int((db_session.lesson_ended_at - db_session.lesson_started_at).total_seconds() / 60)
phase_history = db_session.phase_history or []
items.append(SessionHistoryItem(
session_id=db_session.id, teacher_id=db_session.teacher_id,
class_id=db_session.class_id, subject=db_session.subject, topic=db_session.topic,
lesson_started_at=db_session.lesson_started_at.isoformat() if db_session.lesson_started_at else None,
lesson_ended_at=db_session.lesson_ended_at.isoformat() if db_session.lesson_ended_at else None,
total_duration_minutes=duration,
phases_completed=len(phase_history),
notes=db_session.notes or "", homework=db_session.homework or "",
))
db.close()
return SessionHistoryResponse(
sessions=items, total_count=total_count, limit=limit, offset=offset,
)
except Exception as e:
logger.error(f"Failed to get session history: {e}")
raise HTTPException(status_code=500, detail="Fehler beim Laden der History")