fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,91 @@
"""
Classroom Engine - State Machine fuer Unterrichtsphasen
Dieses Package implementiert eine Finite State Machine fuer die
Steuerung von Unterrichtsstunden mit 5 Phasen:
1. Einstieg (8 Min) - Motivation, Problemstellung
2. Erarbeitung (20 Min) - Hauptarbeitsphase
3. Sicherung (10 Min) - Ergebnisse festhalten
4. Transfer (7 Min) - Anwendung
5. Reflexion (5 Min) - Rueckblick, Hausaufgaben
Features:
- Phasen-Timer mit Countdown und Warnungen
- Phasenspezifische Content-Vorschlaege
- Session-History fuer Analytics
"""
from .models import (
LessonPhase,
PhaseConfig,
LessonSession,
LessonTemplate,
PhaseSuggestion,
Homework,
HomeworkStatus,
PhaseMaterial,
MaterialType,
LESSON_PHASES,
SYSTEM_TEMPLATES,
get_default_durations,
)
from .fsm import LessonStateMachine
from .timer import PhaseTimer
from .suggestions import SuggestionEngine, PHASE_SUGGESTIONS, SUBJECT_SUGGESTIONS
from .context_models import (
MacroPhaseEnum,
EventTypeEnum,
EventStatusEnum,
RoutineTypeEnum,
RecurrencePatternEnum,
TeacherContextDB,
SchoolyearEventDB,
RecurringRoutineDB,
FEDERAL_STATES,
SCHOOL_TYPES,
)
from .antizipation import (
SignalCollector,
RuleEngine,
SuggestionGenerator,
Signals,
Suggestion,
SuggestionTone,
ActiveContext,
ContextType,
)
from .analytics import LessonReflection
__all__ = [
"LessonPhase",
"PhaseConfig",
"LessonSession",
"LessonTemplate",
"PhaseSuggestion",
"Homework",
"HomeworkStatus",
"PhaseMaterial",
"MaterialType",
"LESSON_PHASES",
"SYSTEM_TEMPLATES",
"get_default_durations",
"LessonStateMachine",
"PhaseTimer",
"SuggestionEngine",
"PHASE_SUGGESTIONS",
"SUBJECT_SUGGESTIONS",
# Phase 8: Schuljahres-Kontext
"MacroPhaseEnum",
"EventTypeEnum",
"EventStatusEnum",
"RoutineTypeEnum",
"RecurrencePatternEnum",
"TeacherContextDB",
"SchoolyearEventDB",
"RecurringRoutineDB",
"FEDERAL_STATES",
"SCHOOL_TYPES",
# Analytics
"LessonReflection",
]

View File

@@ -0,0 +1,520 @@
"""
Analytics-Modul fuer Classroom Engine (Phase 5).
Bietet Statistiken und Auswertungen fuer Unterrichtsstunden:
- Phasen-Dauer Statistiken
- Overtime-Analyse
- Lehrer-Dashboard Daten
- Post-Lesson Reflection
WICHTIG: Keine wertenden Metriken (z.B. "Sie haben 70% geredet").
Fokus auf neutrale, hilfreiche Statistiken.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from enum import Enum
# ==================== Analytics Models ====================
@dataclass
class PhaseStatistics:
"""Statistik fuer eine einzelne Phase."""
phase: str
display_name: str
# Dauer-Metriken
planned_duration_seconds: int
actual_duration_seconds: int
difference_seconds: int # positiv = laenger als geplant
# Overtime
had_overtime: bool
overtime_seconds: int = 0
# Erweiterungen
was_extended: bool = False
extension_minutes: int = 0
# Pausen
pause_count: int = 0
total_pause_seconds: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"phase": self.phase,
"display_name": self.display_name,
"planned_duration_seconds": self.planned_duration_seconds,
"actual_duration_seconds": self.actual_duration_seconds,
"difference_seconds": self.difference_seconds,
"difference_formatted": self._format_difference(),
"had_overtime": self.had_overtime,
"overtime_seconds": self.overtime_seconds,
"overtime_formatted": self._format_seconds(self.overtime_seconds),
"was_extended": self.was_extended,
"extension_minutes": self.extension_minutes,
"pause_count": self.pause_count,
"total_pause_seconds": self.total_pause_seconds,
}
def _format_difference(self) -> str:
"""Formatiert die Differenz als +/-MM:SS."""
prefix = "+" if self.difference_seconds >= 0 else ""
return f"{prefix}{self._format_seconds(abs(self.difference_seconds))}"
def _format_seconds(self, seconds: int) -> str:
"""Formatiert Sekunden als MM:SS."""
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
@dataclass
class SessionSummary:
"""
Zusammenfassung einer Unterrichtsstunde.
Wird nach Stundenende generiert und fuer das Lehrer-Dashboard verwendet.
"""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str]
date: datetime
# Dauer
total_duration_seconds: int
planned_duration_seconds: int
# Phasen-Statistiken
phases_completed: int
total_phases: int = 5
phase_statistics: List[PhaseStatistics] = field(default_factory=list)
# Overtime-Zusammenfassung
total_overtime_seconds: int = 0
phases_with_overtime: int = 0
# Pausen-Zusammenfassung
total_pause_count: int = 0
total_pause_seconds: int = 0
# Post-Lesson Reflection
reflection_notes: str = ""
reflection_rating: Optional[int] = None # 1-5 Sterne (optional)
key_learnings: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"session_id": self.session_id,
"teacher_id": self.teacher_id,
"class_id": self.class_id,
"subject": self.subject,
"topic": self.topic,
"date": self.date.isoformat() if self.date else None,
"date_formatted": self._format_date(),
"total_duration_seconds": self.total_duration_seconds,
"total_duration_formatted": self._format_seconds(self.total_duration_seconds),
"planned_duration_seconds": self.planned_duration_seconds,
"planned_duration_formatted": self._format_seconds(self.planned_duration_seconds),
"phases_completed": self.phases_completed,
"total_phases": self.total_phases,
"completion_percentage": round(self.phases_completed / self.total_phases * 100),
"phase_statistics": [p.to_dict() for p in self.phase_statistics],
"total_overtime_seconds": self.total_overtime_seconds,
"total_overtime_formatted": self._format_seconds(self.total_overtime_seconds),
"phases_with_overtime": self.phases_with_overtime,
"total_pause_count": self.total_pause_count,
"total_pause_seconds": self.total_pause_seconds,
"reflection_notes": self.reflection_notes,
"reflection_rating": self.reflection_rating,
"key_learnings": self.key_learnings,
}
def _format_seconds(self, seconds: int) -> str:
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
def _format_date(self) -> str:
if not self.date:
return ""
return self.date.strftime("%d.%m.%Y %H:%M")
@dataclass
class TeacherAnalytics:
"""
Aggregierte Statistiken fuer einen Lehrer.
Zeigt Trends und Muster ueber mehrere Stunden.
"""
teacher_id: str
period_start: datetime
period_end: datetime
# Stunden-Uebersicht
total_sessions: int = 0
completed_sessions: int = 0
total_teaching_minutes: int = 0
# Durchschnittliche Phasendauern
avg_phase_durations: Dict[str, float] = field(default_factory=dict)
# Overtime-Trends
sessions_with_overtime: int = 0
avg_overtime_seconds: float = 0
most_overtime_phase: Optional[str] = None
# Pausen-Statistik
avg_pause_count: float = 0
avg_pause_duration_seconds: float = 0
# Faecher-Verteilung
subjects_taught: Dict[str, int] = field(default_factory=dict)
# Klassen-Verteilung
classes_taught: Dict[str, int] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"teacher_id": self.teacher_id,
"period_start": self.period_start.isoformat() if self.period_start else None,
"period_end": self.period_end.isoformat() if self.period_end else None,
"total_sessions": self.total_sessions,
"completed_sessions": self.completed_sessions,
"total_teaching_minutes": self.total_teaching_minutes,
"total_teaching_hours": round(self.total_teaching_minutes / 60, 1),
"avg_phase_durations": self.avg_phase_durations,
"sessions_with_overtime": self.sessions_with_overtime,
"overtime_percentage": round(self.sessions_with_overtime / max(self.total_sessions, 1) * 100),
"avg_overtime_seconds": round(self.avg_overtime_seconds),
"avg_overtime_formatted": self._format_seconds(int(self.avg_overtime_seconds)),
"most_overtime_phase": self.most_overtime_phase,
"avg_pause_count": round(self.avg_pause_count, 1),
"avg_pause_duration_seconds": round(self.avg_pause_duration_seconds),
"subjects_taught": self.subjects_taught,
"classes_taught": self.classes_taught,
}
def _format_seconds(self, seconds: int) -> str:
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
# ==================== Reflection Model ====================
@dataclass
class LessonReflection:
"""
Post-Lesson Reflection (Feature).
Ermoeglicht Lehrern, nach der Stunde Notizen zu machen.
Keine Bewertung, nur Reflexion.
"""
reflection_id: str
session_id: str
teacher_id: str
# Reflexionsnotizen
notes: str = ""
# Optional: Sterne-Bewertung (selbst-eingeschaetzt)
overall_rating: Optional[int] = None # 1-5
# Was hat gut funktioniert?
what_worked: List[str] = field(default_factory=list)
# Was wuerde ich naechstes Mal anders machen?
improvements: List[str] = field(default_factory=list)
# Notizen fuer naechste Stunde
notes_for_next_lesson: str = ""
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
return {
"reflection_id": self.reflection_id,
"session_id": self.session_id,
"teacher_id": self.teacher_id,
"notes": self.notes,
"overall_rating": self.overall_rating,
"what_worked": self.what_worked,
"improvements": self.improvements,
"notes_for_next_lesson": self.notes_for_next_lesson,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
# ==================== Analytics Calculator ====================
class AnalyticsCalculator:
"""
Berechnet Analytics aus Session-Daten.
Verwendet In-Memory-Daten oder DB-Daten.
"""
PHASE_DISPLAY_NAMES = {
"einstieg": "Einstieg",
"erarbeitung": "Erarbeitung",
"sicherung": "Sicherung",
"transfer": "Transfer",
"reflexion": "Reflexion",
}
@classmethod
def calculate_session_summary(
cls,
session_data: Dict[str, Any],
phase_history: List[Dict[str, Any]]
) -> SessionSummary:
"""
Berechnet die Zusammenfassung einer Session.
Args:
session_data: Session-Dictionary (aus LessonSession.to_dict())
phase_history: Liste der Phasen-History-Eintraege
Returns:
SessionSummary mit allen berechneten Statistiken
"""
# Basis-Daten
session_id = session_data.get("session_id", "")
teacher_id = session_data.get("teacher_id", "")
class_id = session_data.get("class_id", "")
subject = session_data.get("subject", "")
topic = session_data.get("topic")
# Timestamps
lesson_started = session_data.get("lesson_started_at")
lesson_ended = session_data.get("lesson_ended_at")
if isinstance(lesson_started, str):
lesson_started = datetime.fromisoformat(lesson_started.replace("Z", "+00:00"))
if isinstance(lesson_ended, str):
lesson_ended = datetime.fromisoformat(lesson_ended.replace("Z", "+00:00"))
# Dauer berechnen
total_duration = 0
if lesson_started and lesson_ended:
total_duration = int((lesson_ended - lesson_started).total_seconds())
# Geplante Dauer
phase_durations = session_data.get("phase_durations", {})
planned_duration = sum(phase_durations.values()) * 60 # Minuten zu Sekunden
# Phasen-Statistiken berechnen
phase_stats = []
total_overtime = 0
phases_with_overtime = 0
total_pause_count = 0
total_pause_seconds = 0
phases_completed = 0
for entry in phase_history:
phase = entry.get("phase", "")
if phase in ["not_started", "ended"]:
continue
# Geplante Dauer fuer diese Phase
planned_seconds = phase_durations.get(phase, 0) * 60
# Tatsaechliche Dauer
actual_seconds = entry.get("duration_seconds", 0)
if actual_seconds is None:
actual_seconds = 0
# Differenz
difference = actual_seconds - planned_seconds
# Overtime (nur positive Differenz zaehlt)
had_overtime = difference > 0
overtime_seconds = max(0, difference)
if had_overtime:
total_overtime += overtime_seconds
phases_with_overtime += 1
# Pausen
pause_count = entry.get("pause_count", 0) or 0
pause_seconds = entry.get("total_pause_seconds", 0) or 0
total_pause_count += pause_count
total_pause_seconds += pause_seconds
# Phase als abgeschlossen zaehlen
if entry.get("ended_at"):
phases_completed += 1
phase_stats.append(PhaseStatistics(
phase=phase,
display_name=cls.PHASE_DISPLAY_NAMES.get(phase, phase),
planned_duration_seconds=planned_seconds,
actual_duration_seconds=actual_seconds,
difference_seconds=difference,
had_overtime=had_overtime,
overtime_seconds=overtime_seconds,
was_extended=entry.get("was_extended", False),
extension_minutes=entry.get("extension_minutes", 0) or 0,
pause_count=pause_count,
total_pause_seconds=pause_seconds,
))
return SessionSummary(
session_id=session_id,
teacher_id=teacher_id,
class_id=class_id,
subject=subject,
topic=topic,
date=lesson_started or datetime.now(),
total_duration_seconds=total_duration,
planned_duration_seconds=planned_duration,
phases_completed=phases_completed,
total_phases=5,
phase_statistics=phase_stats,
total_overtime_seconds=total_overtime,
phases_with_overtime=phases_with_overtime,
total_pause_count=total_pause_count,
total_pause_seconds=total_pause_seconds,
)
@classmethod
def calculate_teacher_analytics(
cls,
sessions: List[Dict[str, Any]],
period_start: datetime,
period_end: datetime
) -> TeacherAnalytics:
"""
Berechnet aggregierte Statistiken fuer einen Lehrer.
Args:
sessions: Liste von Session-Dictionaries
period_start: Beginn des Zeitraums
period_end: Ende des Zeitraums
Returns:
TeacherAnalytics mit aggregierten Statistiken
"""
if not sessions:
return TeacherAnalytics(
teacher_id="",
period_start=period_start,
period_end=period_end,
)
teacher_id = sessions[0].get("teacher_id", "")
# Basis-Zaehler
total_sessions = len(sessions)
completed_sessions = sum(1 for s in sessions if s.get("lesson_ended_at"))
# Gesamtdauer berechnen
total_minutes = 0
for session in sessions:
started = session.get("lesson_started_at")
ended = session.get("lesson_ended_at")
if started and ended:
if isinstance(started, str):
started = datetime.fromisoformat(started.replace("Z", "+00:00"))
if isinstance(ended, str):
ended = datetime.fromisoformat(ended.replace("Z", "+00:00"))
total_minutes += (ended - started).total_seconds() / 60
# Durchschnittliche Phasendauern
phase_durations_sum: Dict[str, List[int]] = {
"einstieg": [],
"erarbeitung": [],
"sicherung": [],
"transfer": [],
"reflexion": [],
}
# Overtime-Tracking
overtime_count = 0
overtime_seconds_total = 0
phase_overtime: Dict[str, int] = {}
# Pausen-Tracking
pause_counts = []
pause_durations = []
# Faecher und Klassen
subjects: Dict[str, int] = {}
classes: Dict[str, int] = {}
for session in sessions:
# Fach und Klasse zaehlen
subject = session.get("subject", "")
class_id = session.get("class_id", "")
subjects[subject] = subjects.get(subject, 0) + 1
classes[class_id] = classes.get(class_id, 0) + 1
# Phase History analysieren
history = session.get("phase_history", [])
session_has_overtime = False
session_pause_count = 0
session_pause_duration = 0
phase_durations_dict = session.get("phase_durations", {})
for entry in history:
phase = entry.get("phase", "")
if phase in phase_durations_sum:
duration = entry.get("duration_seconds", 0) or 0
phase_durations_sum[phase].append(duration)
# Overtime berechnen
planned = phase_durations_dict.get(phase, 0) * 60
if duration > planned:
overtime = duration - planned
overtime_seconds_total += overtime
session_has_overtime = True
phase_overtime[phase] = phase_overtime.get(phase, 0) + overtime
# Pausen zaehlen
session_pause_count += entry.get("pause_count", 0) or 0
session_pause_duration += entry.get("total_pause_seconds", 0) or 0
if session_has_overtime:
overtime_count += 1
pause_counts.append(session_pause_count)
pause_durations.append(session_pause_duration)
# Durchschnitte berechnen
avg_durations = {}
for phase, durations in phase_durations_sum.items():
if durations:
avg_durations[phase] = round(sum(durations) / len(durations))
else:
avg_durations[phase] = 0
# Phase mit meistem Overtime finden
most_overtime_phase = None
if phase_overtime:
most_overtime_phase = max(phase_overtime, key=phase_overtime.get)
return TeacherAnalytics(
teacher_id=teacher_id,
period_start=period_start,
period_end=period_end,
total_sessions=total_sessions,
completed_sessions=completed_sessions,
total_teaching_minutes=int(total_minutes),
avg_phase_durations=avg_durations,
sessions_with_overtime=overtime_count,
avg_overtime_seconds=overtime_seconds_total / max(total_sessions, 1),
most_overtime_phase=most_overtime_phase,
avg_pause_count=sum(pause_counts) / max(len(pause_counts), 1),
avg_pause_duration_seconds=sum(pause_durations) / max(len(pause_durations), 1),
subjects_taught=subjects,
classes_taught=classes,
)

View File

@@ -0,0 +1,676 @@
"""
Antizipations-Engine fuer proaktive Vorschlaege (Phase 8b).
Die Engine sammelt Signale aus verschiedenen Quellen und generiert
kontextbasierte Vorschlaege fuer Lehrer basierend auf definierten Regeln.
Architektur:
1. SignalCollector - Sammelt Inputs (Zeit, Nutzung, Events)
2. RuleEngine - Evaluiert Regeln gegen Signale
3. SuggestionGenerator - Generiert priorisierte Vorschlaege
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from enum import Enum
import logging
logger = logging.getLogger(__name__)
# ==================== Enums & Types ====================
class SuggestionTone(str, Enum):
"""Ton/Dringlichkeit eines Vorschlags."""
HINT = "hint" # Sanfter Hinweis
SUGGESTION = "suggestion" # Aktiver Vorschlag
REMINDER = "reminder" # Erinnerung
URGENT = "urgent" # Dringend
class ContextType(str, Enum):
"""Typ eines aktiven Kontexts."""
EVENT_WINDOW = "event_window" # Event steht bevor
ROUTINE = "routine" # Routine heute
PHASE = "phase" # Makro-Phase bedingt
TIME = "time" # Zeitbasiert (Ferien, Wochenende)
@dataclass
class Signal:
"""Ein einzelnes Signal aus einer Quelle."""
name: str
value: Any
source: str # "calendar", "usage", "events", "routines"
@dataclass
class ActiveContext:
"""Ein aktiver Kontext der Vorschlaege beeinflusst."""
id: str
context_type: ContextType
label: str
data: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Suggestion:
"""Ein generierter Vorschlag."""
id: str
title: str
description: str
tone: SuggestionTone
action_url: Optional[str] = None
badge: Optional[str] = None # z.B. "in 7 Tagen"
priority: int = 50 # 0-100, hoeher = wichtiger
rule_id: str = ""
icon: str = "lightbulb"
@dataclass
class Signals:
"""Container fuer alle gesammelten Signale."""
# Zeit/Kalender
current_week: int = 1
weeks_since_start: int = 0
is_weekend: bool = False
is_before_holidays: bool = False
days_until_holidays: int = 999
# Makro-Phase
macro_phase: str = "onboarding"
onboarding_completed: bool = False
# Produktnutzung
classes_count: int = 0
has_classes: bool = False
has_schedule: bool = False
# Events
exams_scheduled_count: int = 0
exams_in_7_days: List[Dict] = field(default_factory=list)
exams_past_ungraded: List[Dict] = field(default_factory=list)
upcoming_events: List[Dict] = field(default_factory=list)
trips_in_30_days: List[Dict] = field(default_factory=list)
parent_evenings_soon: List[Dict] = field(default_factory=list)
# Routinen
routines_today: List[Dict] = field(default_factory=list)
has_conference_today: bool = False
# Statistiken (aus Analytics)
corrections_pending: int = 0
grades_completion_ratio: float = 0.0
# ==================== Signal Collector ====================
class SignalCollector:
"""
Sammelt Signale aus verschiedenen Quellen.
Quellen:
- TeacherContext (Makro-Phase, Schuljahr)
- SchoolyearEvents (Klausuren, Elternabende, etc.)
- RecurringRoutines (Konferenzen heute)
- Zeit/Kalender (Wochenende, Ferien)
"""
def __init__(self, db_session=None):
self.db = db_session
def collect(self, teacher_id: str) -> Signals:
"""Sammelt alle Signale fuer einen Lehrer."""
signals = Signals()
# Zeit-Signale
self._collect_time_signals(signals)
if self.db:
# Kontext-Signale
self._collect_context_signals(signals, teacher_id)
# Event-Signale
self._collect_event_signals(signals, teacher_id)
# Routine-Signale
self._collect_routine_signals(signals, teacher_id)
return signals
def _collect_time_signals(self, signals: Signals):
"""Sammelt zeitbasierte Signale."""
now = datetime.utcnow()
signals.is_weekend = now.weekday() >= 5
# TODO: Ferien-Kalender pro Bundesland integrieren
# Fuer jetzt: Dummy-Werte
signals.is_before_holidays = False
signals.days_until_holidays = 999
def _collect_context_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus dem Teacher-Kontext."""
from .repository import TeacherContextRepository
try:
repo = TeacherContextRepository(self.db)
context = repo.get_or_create(teacher_id)
signals.macro_phase = context.macro_phase.value
signals.current_week = context.current_week or 1
signals.onboarding_completed = context.onboarding_completed
signals.has_classes = context.has_classes
signals.has_schedule = context.has_schedule
signals.classes_count = 1 if context.has_classes else 0
# Wochen seit Schuljahresstart berechnen
if context.schoolyear_start:
delta = datetime.utcnow() - context.schoolyear_start
signals.weeks_since_start = max(0, delta.days // 7)
signals.is_before_holidays = context.is_before_holidays
except Exception as e:
logger.warning(f"Failed to collect context signals: {e}")
def _collect_event_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus Events."""
from .repository import SchoolyearEventRepository
try:
repo = SchoolyearEventRepository(self.db)
now = datetime.utcnow()
# Alle anstehenden Events (30 Tage)
upcoming = repo.get_upcoming(teacher_id, days=30, limit=20)
signals.upcoming_events = [repo.to_dict(e) for e in upcoming]
# Klausuren in den naechsten 7 Tagen
seven_days = now + timedelta(days=7)
signals.exams_in_7_days = [
repo.to_dict(e) for e in upcoming
if e.event_type.value == "exam" and e.start_date <= seven_days
]
signals.exams_scheduled_count = len([
e for e in upcoming if e.event_type.value == "exam"
])
# Klassenfahrten in 30 Tagen
signals.trips_in_30_days = [
repo.to_dict(e) for e in upcoming
if e.event_type.value == "trip"
]
# Elternabende bald
signals.parent_evenings_soon = [
repo.to_dict(e) for e in upcoming
if e.event_type.value in ("parent_evening", "parent_consultation")
]
except Exception as e:
logger.warning(f"Failed to collect event signals: {e}")
def _collect_routine_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus Routinen."""
from .repository import RecurringRoutineRepository
try:
repo = RecurringRoutineRepository(self.db)
today_routines = repo.get_today(teacher_id)
signals.routines_today = [repo.to_dict(r) for r in today_routines]
signals.has_conference_today = any(
r.routine_type.value in ("teacher_conference", "subject_conference")
for r in today_routines
)
except Exception as e:
logger.warning(f"Failed to collect routine signals: {e}")
# ==================== Rule Engine ====================
@dataclass
class Rule:
"""Eine Regel die Signale zu Vorschlaegen mappt."""
id: str
name: str
description: str
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
"""Evaluiert die Regel und gibt einen Vorschlag zurueck oder None."""
raise NotImplementedError()
class R01_CreateClasses(Rule):
"""Klassen anlegen wenn noch keine vorhanden."""
def __init__(self):
super().__init__(
id="R01",
name="Klassen anlegen",
description="Empfiehlt Klassen anzulegen bei neuem Lehrer"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.macro_phase == "onboarding" and not signals.has_classes:
return Suggestion(
id="suggest_create_classes",
title="Klassen anlegen",
description="Legen Sie Ihre Klassen an, um den vollen Funktionsumfang zu nutzen.",
tone=SuggestionTone.HINT,
priority=90,
rule_id=self.id,
icon="group_add",
action_url="/classes/new",
)
return None
class R02_PrepareRubric(Rule):
"""Erwartungshorizont erstellen wenn Klausur in 7 Tagen."""
def __init__(self):
super().__init__(
id="R02",
name="Erwartungshorizont",
description="Empfiehlt Erwartungshorizont vor Klausur"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.exams_in_7_days:
exam = signals.exams_in_7_days[0]
# Pruefen ob Vorbereitung noch nicht erledigt
if not exam.get("preparation_done", False):
days = 7 # Vereinfacht
return Suggestion(
id=f"suggest_rubric_{exam['id'][:8]}",
title="Erwartungshorizont erstellen",
description=f"Klausur '{exam['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge=f"in {days} Tagen",
priority=80,
rule_id=self.id,
icon="assignment",
action_url=f"/exams/{exam['id']}/rubric",
)
return None
class R03_StartCorrection(Rule):
"""Korrektur starten nach Klausur."""
def __init__(self):
super().__init__(
id="R03",
name="Korrektur starten",
description="Empfiehlt Korrektur nach Klausur"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.exams_past_ungraded:
exam = signals.exams_past_ungraded[0]
return Suggestion(
id=f"suggest_correction_{exam['id'][:8]}",
title="Korrektur-Setup starten",
description=f"Klausur '{exam['title']}' ist geschrieben.",
tone=SuggestionTone.HINT,
badge="bereit",
priority=75,
rule_id=self.id,
icon="rate_review",
action_url=f"/exams/{exam['id']}/correct",
)
return None
class R05_PrepareAgenda(Rule):
"""Agenda vorbereiten wenn Konferenz heute."""
def __init__(self):
super().__init__(
id="R05",
name="Konferenz-Agenda",
description="Empfiehlt Agenda wenn Konferenz heute"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.has_conference_today:
# Finde die Konferenz
conf = next(
(r for r in signals.routines_today
if r.get("routine_type") in ("teacher_conference", "subject_conference")),
None
)
if conf:
return Suggestion(
id="suggest_agenda",
title="Konferenz-Agenda vorbereiten",
description=f"{conf.get('title', 'Konferenz')} heute.",
tone=SuggestionTone.SUGGESTION,
badge="heute",
priority=70,
rule_id=self.id,
icon="event_note",
)
return None
class R07_PlanFirstExam(Rule):
"""Erste Klausur planen nach 4 Wochen."""
def __init__(self):
super().__init__(
id="R07",
name="Erste Arbeit planen",
description="Empfiehlt erste Klausur nach Anlaufphase"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if (signals.weeks_since_start >= 4 and
signals.exams_scheduled_count == 0 and
signals.has_classes):
return Suggestion(
id="suggest_first_exam",
title="Erste Klassenarbeit planen",
description="Nach 4 Wochen Unterricht ist ein guter Zeitpunkt fuer die erste Leistungsueberpruefung.",
tone=SuggestionTone.SUGGESTION,
priority=60,
rule_id=self.id,
icon="quiz",
action_url="/exams/new",
)
return None
class R08_CorrectionMode(Rule):
"""Korrekturmodus vor Ferien."""
def __init__(self):
super().__init__(
id="R08",
name="Ferien-Korrekturmodus",
description="Empfiehlt Korrekturen vor Ferien"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.is_before_holidays and signals.corrections_pending > 0:
return Suggestion(
id="suggest_correction_mode",
title="Ferien-Korrekturmodus",
description=f"{signals.corrections_pending} Korrekturen noch offen vor den Ferien.",
tone=SuggestionTone.REMINDER,
badge=f"{signals.days_until_holidays}d bis Ferien",
priority=65,
rule_id=self.id,
icon="grading",
)
return None
class R09_TripChecklist(Rule):
"""Klassenfahrt-Checkliste wenn Fahrt in 30 Tagen."""
def __init__(self):
super().__init__(
id="R09",
name="Klassenfahrt-Checkliste",
description="Empfiehlt Checkliste vor Klassenfahrt"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.trips_in_30_days:
trip = signals.trips_in_30_days[0]
return Suggestion(
id=f"suggest_trip_{trip['id'][:8]}",
title="Klassenfahrt-Checkliste",
description=f"'{trip['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge="in 30 Tagen",
priority=55,
rule_id=self.id,
icon="luggage",
action_url=f"/trips/{trip['id']}/checklist",
)
return None
class R10_CompleteGrades(Rule):
"""Noten vervollstaendigen vor Halbjahresende."""
def __init__(self):
super().__init__(
id="R10",
name="Noten vervollstaendigen",
description="Empfiehlt Noten vor Notenschluss"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if (signals.macro_phase in ("halbjahresabschluss", "jahresabschluss") and
signals.grades_completion_ratio < 0.8):
pct = int(signals.grades_completion_ratio * 100)
return Suggestion(
id="suggest_complete_grades",
title="Noten vervollstaendigen",
description=f"Nur {pct}% der Noten eingetragen. Notenschluss naht!",
tone=SuggestionTone.REMINDER,
priority=85,
rule_id=self.id,
icon="calculate",
action_url="/grades",
)
return None
class R11_SetupSchedule(Rule):
"""Stundenplan einrichten wenn noch nicht vorhanden."""
def __init__(self):
super().__init__(
id="R11",
name="Stundenplan einrichten",
description="Empfiehlt Stundenplan-Setup"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.macro_phase == "onboarding" and not signals.has_schedule:
return Suggestion(
id="suggest_setup_schedule",
title="Stundenplan einrichten",
description="Richten Sie Ihren Stundenplan ein fuer personalisierte Vorschlaege.",
tone=SuggestionTone.HINT,
priority=85,
rule_id=self.id,
icon="calendar_month",
action_url="/schedule/setup",
)
return None
class R12_ParentEvening(Rule):
"""Elternabend vorbereiten."""
def __init__(self):
super().__init__(
id="R12",
name="Elternabend vorbereiten",
description="Empfiehlt Vorbereitung vor Elternabend"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.parent_evenings_soon:
event = signals.parent_evenings_soon[0]
return Suggestion(
id=f"suggest_parent_{event['id'][:8]}",
title="Elternabend vorbereiten",
description=f"'{event['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge="bald",
priority=65,
rule_id=self.id,
icon="family_restroom",
action_url=f"/events/{event['id']}/prepare",
)
return None
class RuleEngine:
"""
Evaluiert alle Regeln gegen die gesammelten Signale.
"""
def __init__(self):
self.rules: List[Rule] = [
R01_CreateClasses(),
R02_PrepareRubric(),
R03_StartCorrection(),
R05_PrepareAgenda(),
R07_PlanFirstExam(),
R08_CorrectionMode(),
R09_TripChecklist(),
R10_CompleteGrades(),
R11_SetupSchedule(),
R12_ParentEvening(),
]
def evaluate(self, signals: Signals) -> List[Suggestion]:
"""Evaluiert alle Regeln und gibt passende Vorschlaege zurueck."""
suggestions = []
for rule in self.rules:
try:
suggestion = rule.evaluate(signals)
if suggestion:
suggestions.append(suggestion)
except Exception as e:
logger.warning(f"Rule {rule.id} failed: {e}")
# Nach Prioritaet sortieren (hoechste zuerst)
suggestions.sort(key=lambda s: s.priority, reverse=True)
return suggestions
# ==================== Suggestion Generator ====================
class SuggestionGenerator:
"""
Hauptklasse die Signale sammelt, Regeln evaluiert und
Vorschlaege generiert.
"""
def __init__(self, db_session=None):
self.collector = SignalCollector(db_session)
self.rule_engine = RuleEngine()
def generate(self, teacher_id: str, limit: int = 5) -> Dict[str, Any]:
"""
Generiert Vorschlaege fuer einen Lehrer.
Returns:
{
"active_contexts": [...],
"suggestions": [...],
"signals_summary": {...}
}
"""
# 1. Signale sammeln
signals = self.collector.collect(teacher_id)
# 2. Regeln evaluieren
all_suggestions = self.rule_engine.evaluate(signals)
# 3. Aktive Kontexte bestimmen
active_contexts = self._determine_active_contexts(signals)
# 4. Top N Vorschlaege
top_suggestions = all_suggestions[:limit]
return {
"active_contexts": [
{
"id": ctx.id,
"type": ctx.context_type.value,
"label": ctx.label,
}
for ctx in active_contexts
],
"suggestions": [
{
"id": s.id,
"title": s.title,
"description": s.description,
"tone": s.tone.value,
"badge": s.badge,
"priority": s.priority,
"icon": s.icon,
"action_url": s.action_url,
}
for s in top_suggestions
],
"signals_summary": {
"macro_phase": signals.macro_phase,
"current_week": signals.current_week,
"has_classes": signals.has_classes,
"exams_soon": len(signals.exams_in_7_days),
"routines_today": len(signals.routines_today),
},
"total_suggestions": len(all_suggestions),
}
def _determine_active_contexts(self, signals: Signals) -> List[ActiveContext]:
"""Bestimmt die aktiven Kontexte basierend auf Signalen."""
contexts = []
# Event-Kontexte
if signals.exams_in_7_days:
contexts.append(ActiveContext(
id="EXAM_IN_7_DAYS",
context_type=ContextType.EVENT_WINDOW,
label="Klausur in 7 Tagen",
))
if signals.trips_in_30_days:
contexts.append(ActiveContext(
id="TRIP_UPCOMING",
context_type=ContextType.EVENT_WINDOW,
label="Klassenfahrt geplant",
))
# Routine-Kontexte
if signals.has_conference_today:
contexts.append(ActiveContext(
id="CONFERENCE_TODAY",
context_type=ContextType.ROUTINE,
label="Konferenz heute",
))
# Zeit-Kontexte
if signals.is_weekend:
contexts.append(ActiveContext(
id="WEEKEND",
context_type=ContextType.TIME,
label="Wochenende",
))
if signals.is_before_holidays:
contexts.append(ActiveContext(
id="BEFORE_HOLIDAYS",
context_type=ContextType.TIME,
label="Vor den Ferien",
))
# Phase-Kontexte
if signals.macro_phase == "onboarding":
contexts.append(ActiveContext(
id="ONBOARDING",
context_type=ContextType.PHASE,
label="Einrichtung",
))
elif signals.macro_phase in ("halbjahresabschluss", "jahresabschluss"):
contexts.append(ActiveContext(
id="GRADE_PERIOD",
context_type=ContextType.PHASE,
label="Notenphase",
))
return contexts

View File

@@ -0,0 +1,291 @@
"""
SQLAlchemy Database Models fuer Schuljahres-Kontext (Phase 8).
Erweitert das Companion-Modul um ein 2-Schichten-Modell:
- Makro-Ebene: 7 Schuljahres-Phasen (langsam wechselnd)
- Mikro-Ebene: Events, Routinen, Arbeitsmodi (tagesaktuell)
"""
from datetime import datetime, time
from sqlalchemy import (
Column, String, Integer, DateTime, JSON,
Boolean, Text, Enum as SQLEnum, Time
)
import enum
import uuid
from .database import Base
# Exports
__all__ = [
"MacroPhaseEnum",
"EventTypeEnum",
"EventStatusEnum",
"RoutineTypeEnum",
"RecurrencePatternEnum",
"TeacherContextDB",
"SchoolyearEventDB",
"RecurringRoutineDB",
"FEDERAL_STATES",
"SCHOOL_TYPES",
]
# ==================== Konstanten ====================
FEDERAL_STATES = {
"BW": "Baden-Wuerttemberg",
"BY": "Bayern",
"BE": "Berlin",
"BB": "Brandenburg",
"HB": "Bremen",
"HH": "Hamburg",
"HE": "Hessen",
"MV": "Mecklenburg-Vorpommern",
"NI": "Niedersachsen",
"NW": "Nordrhein-Westfalen",
"RP": "Rheinland-Pfalz",
"SL": "Saarland",
"SN": "Sachsen",
"ST": "Sachsen-Anhalt",
"SH": "Schleswig-Holstein",
"TH": "Thueringen",
}
SCHOOL_TYPES = {
"grundschule": "Grundschule",
"hauptschule": "Hauptschule",
"realschule": "Realschule",
"gymnasium": "Gymnasium",
"gesamtschule": "Gesamtschule",
"foerderschule": "Foerderschule",
"berufsschule": "Berufsschule",
"gemeinschaftsschule": "Gemeinschaftsschule",
}
# ==================== Enums ====================
class MacroPhaseEnum(str, enum.Enum):
"""
7 Schuljahres-Phasen (Makro-State).
Wechselt alle paar Wochen basierend auf Schulkalender und Nutzungsverhalten.
"""
ONBOARDING = "onboarding" # Ersteinrichtung (Klassen, Stundenplan)
SCHULJAHRESSTART = "schuljahresstart" # Erste 2-3 Wochen
UNTERRICHTSAUFBAU = "unterrichtsaufbau" # Routinen etablieren
LEISTUNGSPHASE_1 = "leistungsphase_1" # Erste Klausuren
HALBJAHRESABSCHLUSS = "halbjahresabschluss" # Notenschluss, Zeugnisse
LEISTUNGSPHASE_2 = "leistungsphase_2" # Pruefungsvorbereitung
JAHRESABSCHLUSS = "jahresabschluss" # Finale Noten, Versetzung
class EventTypeEnum(str, enum.Enum):
"""Event-Typen fuer Schuljahr-Events."""
EXAM = "exam" # Klassenarbeit, Klausur
PARENT_EVENING = "parent_evening" # Elternabend
TRIP = "trip" # Klassenfahrt, Ausflug
PROJECT = "project" # Projektwoche
INTERNSHIP = "internship" # Praktikum
PRESENTATION = "presentation" # Referate, Praesentationen
SPORTS_DAY = "sports_day" # Sporttag, Bundesjugendspiele
SCHOOL_FESTIVAL = "school_festival" # Schulfest
PARENT_CONSULTATION = "parent_consultation" # Elternsprechtag
GRADE_DEADLINE = "grade_deadline" # Notenschluss
REPORT_CARDS = "report_cards" # Zeugnisausgabe
HOLIDAY_START = "holiday_start" # Ferienbeginn
HOLIDAY_END = "holiday_end" # Ferienende
OTHER = "other"
class EventStatusEnum(str, enum.Enum):
"""Status eines Events."""
PLANNED = "planned"
IN_PROGRESS = "in_progress"
DONE = "done"
CANCELLED = "cancelled"
class RoutineTypeEnum(str, enum.Enum):
"""Typen wiederkehrender Routinen."""
TEACHER_CONFERENCE = "teacher_conference" # Lehrerkonferenz
SUBJECT_CONFERENCE = "subject_conference" # Fachkonferenz
OFFICE_HOURS = "office_hours" # Sprechstunde
TEAM_MEETING = "team_meeting" # Teamsitzung
SUPERVISION = "supervision" # Pausenaufsicht
CORRECTION_TIME = "correction_time" # Korrekturzeit
PREP_TIME = "prep_time" # Vorbereitungszeit
OTHER = "other"
class RecurrencePatternEnum(str, enum.Enum):
"""Wiederholungsmuster fuer Routinen."""
DAILY = "daily"
WEEKLY = "weekly"
BIWEEKLY = "biweekly"
MONTHLY = "monthly"
# ==================== Models ====================
class TeacherContextDB(Base):
"""
Lehrer-Kontext fuer Schuljahres-Begleitung (Phase 8).
Speichert den Makro-Kontext eines Lehrers:
- Bundesland (fuer Schulkalender/Ferien)
- Schulart
- Aktuelles Schuljahr
- Aktuelle Makro-Phase
Ein Lehrer hat genau einen Context-Eintrag.
"""
__tablename__ = 'teacher_contexts'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
teacher_id = Column(String(100), unique=True, nullable=False, index=True)
# Schul-Kontext
federal_state = Column(String(10), default="BY") # BY, NRW, etc.
school_type = Column(String(50), default="gymnasium") # gymnasium, realschule, etc.
# Schuljahr
schoolyear = Column(String(20), default="2024-2025") # z.B. "2024-2025"
schoolyear_start = Column(DateTime, nullable=True) # Erster Schultag
# Aktueller Stand (Makro-Phase)
macro_phase = Column(
SQLEnum(MacroPhaseEnum),
default=MacroPhaseEnum.ONBOARDING,
nullable=False
)
current_week = Column(Integer, default=1) # Schulwoche 1-52
# Berechnete Flags (werden beim Abrufen aktualisiert)
is_exam_period = Column(Boolean, default=False)
is_before_holidays = Column(Boolean, default=False)
# Onboarding-Status
onboarding_completed = Column(Boolean, default=False)
has_classes = Column(Boolean, default=False)
has_schedule = Column(Boolean, default=False)
# Metadaten
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<TeacherContext {self.teacher_id} ({self.macro_phase.value})>"
class SchoolyearEventDB(Base):
"""
Schuljahr-Event (Phase 8).
Ein einmaliges Event im Schuljahr wie:
- Klassenarbeit
- Elternabend
- Klassenfahrt
- Projektwoche
Events haben einen Zeitraum und optionale Klassen-/Fach-Zuordnung.
"""
__tablename__ = 'schoolyear_events'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
teacher_id = Column(String(100), nullable=False, index=True)
# Event-Typ und Titel
event_type = Column(
SQLEnum(EventTypeEnum),
default=EventTypeEnum.OTHER,
nullable=False
)
title = Column(String(300), nullable=False)
description = Column(Text, default="")
# Zeitraum
start_date = Column(DateTime, nullable=False, index=True)
end_date = Column(DateTime, nullable=True) # Null = eintaegiges Event
# Optionale Zuordnung
class_id = Column(String(100), nullable=True, index=True)
subject = Column(String(100), nullable=True)
# Status
status = Column(
SQLEnum(EventStatusEnum),
default=EventStatusEnum.PLANNED,
nullable=False,
index=True
)
# Flags fuer Antizipation
needs_preparation = Column(Boolean, default=True) # Braucht Vorbereitung?
preparation_done = Column(Boolean, default=False) # Vorbereitung erledigt?
reminder_days_before = Column(Integer, default=7) # Tage vorher erinnern
# Flexible Zusatzdaten (z.B. Rubric-ID fuer Klausur)
extra_data = Column(JSON, default=dict) # Renamed from 'metadata' which is reserved in SQLAlchemy
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<SchoolyearEvent {self.title} ({self.event_type.value})>"
class RecurringRoutineDB(Base):
"""
Wiederkehrende Routine (Phase 8).
Eine regelmaessig wiederkehrende Aktivitaet wie:
- Lehrerkonferenz (woechentlich/monatlich)
- Fachkonferenz
- Sprechstunde
- Korrekturzeit
Routinen wiederholen sich nach einem bestimmten Muster.
"""
__tablename__ = 'recurring_routines'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
teacher_id = Column(String(100), nullable=False, index=True)
# Routine-Typ und Titel
routine_type = Column(
SQLEnum(RoutineTypeEnum),
default=RoutineTypeEnum.OTHER,
nullable=False
)
title = Column(String(300), nullable=False)
description = Column(Text, default="")
# Wiederholung
recurrence_pattern = Column(
SQLEnum(RecurrencePatternEnum),
default=RecurrencePatternEnum.WEEKLY,
nullable=False
)
day_of_week = Column(Integer, nullable=True) # 0=Mo, 6=So (Null wenn daily/monthly)
day_of_month = Column(Integer, nullable=True) # 1-31 fuer monthly
time_of_day = Column(Time, nullable=True) # z.B. 14:00
# Dauer in Minuten
duration_minutes = Column(Integer, default=60)
# Aktiv?
is_active = Column(Boolean, default=True)
# Startdatum (ab wann gilt die Routine?)
valid_from = Column(DateTime, nullable=True)
valid_until = Column(DateTime, nullable=True)
# Metadaten
extra_data = Column(JSON, default=dict) # Renamed from 'metadata' which is reserved in SQLAlchemy
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<RecurringRoutine {self.title} ({self.recurrence_pattern.value})>"

View File

@@ -0,0 +1,49 @@
"""
Database Configuration fuer Classroom Engine.
Stellt PostgreSQL-Anbindung fuer Session-Persistenz bereit.
Sessions ueberleben Server-Neustarts (Feature f22).
"""
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Database URL from environment (nutzt gleiche DB wie Backend)
# Korrigiert postgres:// zu postgresql:// fuer SQLAlchemy 2.0 Kompatibilitaet
_raw_url = os.getenv(
"DATABASE_URL",
"postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot"
)
# SQLAlchemy 2.0 erfordert "postgresql://" statt "postgres://"
DATABASE_URL = _raw_url.replace("postgres://", "postgresql://", 1) if _raw_url.startswith("postgres://") else _raw_url
# Engine configuration
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
echo=os.getenv("SQL_ECHO", "false").lower() == "true"
)
# Declarative Base
Base = declarative_base()
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""Database dependency for FastAPI endpoints."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Erstellt alle Tabellen (fuer Entwicklung)."""
from . import db_models # Import models to register them
Base.metadata.create_all(bind=engine)

View File

@@ -0,0 +1,429 @@
"""
SQLAlchemy Database Models fuer Classroom Engine (Feature f13).
Persistiert Unterrichtsstunden und deren Verlauf in PostgreSQL.
"""
from datetime import datetime
from sqlalchemy import (
Column, String, Integer, Float, DateTime, JSON,
Boolean, Text, Enum as SQLEnum, ForeignKey
)
from sqlalchemy.orm import relationship
import enum
import uuid
from .database import Base
# Import Phase 8 Context Models
from .context_models import (
MacroPhaseEnum,
EventTypeEnum,
EventStatusEnum,
RoutineTypeEnum,
RecurrencePatternEnum,
TeacherContextDB,
SchoolyearEventDB,
RecurringRoutineDB,
FEDERAL_STATES,
SCHOOL_TYPES,
)
# Exports
__all__ = [
"LessonPhaseEnum",
"LessonSessionDB",
"PhaseHistoryDB",
"LessonTemplateDB",
"TeacherSettingsDB",
"HomeworkStatusEnum",
"HomeworkDB",
"MaterialTypeEnum",
"PhaseMaterialDB",
"LessonReflectionDB",
"FeedbackTypeEnum",
"FeedbackStatusEnum",
"FeedbackPriorityEnum",
"TeacherFeedbackDB",
# Phase 8: Schuljahres-Kontext
"MacroPhaseEnum",
"EventTypeEnum",
"EventStatusEnum",
"RoutineTypeEnum",
"RecurrencePatternEnum",
"TeacherContextDB",
"SchoolyearEventDB",
"RecurringRoutineDB",
"FEDERAL_STATES",
"SCHOOL_TYPES",
]
class LessonPhaseEnum(str, enum.Enum):
"""Unterrichtsphasen als DB-Enum."""
NOT_STARTED = "not_started"
EINSTIEG = "einstieg"
ERARBEITUNG = "erarbeitung"
SICHERUNG = "sicherung"
TRANSFER = "transfer"
REFLEXION = "reflexion"
ENDED = "ended"
class LessonSessionDB(Base):
"""
Persistierte Unterrichtsstunde.
Speichert alle Session-Daten inklusive Timer-Status und History.
"""
__tablename__ = 'lesson_sessions'
# Primary Key
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# Beziehungen
teacher_id = Column(String(100), nullable=False, index=True)
class_id = Column(String(50), nullable=False, index=True)
# Session Metadaten
subject = Column(String(100), nullable=False)
topic = Column(String(500))
# Status
current_phase = Column(
SQLEnum(LessonPhaseEnum),
default=LessonPhaseEnum.NOT_STARTED,
nullable=False
)
is_paused = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
lesson_started_at = Column(DateTime)
lesson_ended_at = Column(DateTime)
phase_started_at = Column(DateTime)
pause_started_at = Column(DateTime)
# Timer-Daten
total_paused_seconds = Column(Integer, default=0)
phase_durations = Column(JSON, default=dict) # {"einstieg": 8, ...}
# History & Notizen
phase_history = Column(JSON, default=list) # [{phase, started_at, ended_at, duration}]
notes = Column(Text, default="")
homework = Column(Text, default="")
# Relationship zu PhaseHistory (optional fuer detaillierte Abfragen)
history_entries = relationship("PhaseHistoryDB", back_populates="session", cascade="all, delete-orphan")
def __repr__(self):
return f"<LessonSession {self.id}: {self.subject} ({self.current_phase.value})>"
class PhaseHistoryDB(Base):
"""
Einzelner Phasen-Verlaufseintrag.
Ermoeglicht detaillierte Statistiken ueber Phasendauern.
"""
__tablename__ = 'lesson_phase_history'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=False, index=True)
phase = Column(SQLEnum(LessonPhaseEnum), nullable=False)
started_at = Column(DateTime, nullable=False)
ended_at = Column(DateTime)
duration_seconds = Column(Integer)
# Zusaetzliche Metriken
was_extended = Column(Boolean, default=False)
extension_minutes = Column(Integer, default=0)
pause_count = Column(Integer, default=0)
total_pause_seconds = Column(Integer, default=0)
# Relationship
session = relationship("LessonSessionDB", back_populates="history_entries")
def __repr__(self):
return f"<PhaseHistory {self.phase.value} ({self.duration_seconds}s)>"
class LessonTemplateDB(Base):
"""
Stunden-Vorlage (Feature f37).
Ermoeglicht Speicherung von wiederverwendbaren Stundenkonfigurationen.
"""
__tablename__ = 'lesson_templates'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
teacher_id = Column(String(100), nullable=False, index=True)
# Basis-Infos
name = Column(String(200), nullable=False)
description = Column(Text, default="")
subject = Column(String(100), default="")
grade_level = Column(String(50), default="")
# Phasenkonfiguration
phase_durations = Column(JSON, default=dict)
# Vorbelegungen
default_topic = Column(String(500), default="")
default_notes = Column(Text, default="")
# Metadaten
is_public = Column(Boolean, default=False)
usage_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<LessonTemplate {self.name} ({self.teacher_id})>"
class TeacherSettingsDB(Base):
"""
Lehrer-spezifische Einstellungen (Feature f16).
Speichert individuelle Praeferenzen wie Phasendauern.
"""
__tablename__ = 'teacher_settings'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
teacher_id = Column(String(100), unique=True, nullable=False, index=True)
# Individuelle Phasendauern
default_phase_durations = Column(JSON, default=dict)
# UI Praeferenzen
audio_enabled = Column(Boolean, default=True)
high_contrast = Column(Boolean, default=False)
# Statistik-Einstellungen
show_statistics = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<TeacherSettings {self.teacher_id}>"
class HomeworkStatusEnum(str, enum.Enum):
"""Status-Enum fuer Hausaufgaben."""
ASSIGNED = "assigned"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
OVERDUE = "overdue"
class HomeworkDB(Base):
"""
Hausaufgaben-Tracking (Feature f20).
Ermoeglicht die Verfolgung von Hausaufgaben ueber Sessions hinweg.
"""
__tablename__ = 'homework_assignments'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
teacher_id = Column(String(100), nullable=False, index=True)
class_id = Column(String(50), nullable=False, index=True)
subject = Column(String(100), nullable=False)
# Aufgaben-Details
title = Column(String(300), nullable=False)
description = Column(Text, default="")
# Verknuepfung zur Session (optional)
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=True, index=True)
# Faelligkeit und Status
due_date = Column(DateTime, nullable=True, index=True)
status = Column(
SQLEnum(HomeworkStatusEnum),
default=HomeworkStatusEnum.ASSIGNED,
nullable=False
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<Homework {self.title} ({self.status.value})>"
class MaterialTypeEnum(str, enum.Enum):
"""Typ-Enum fuer Materialien."""
DOCUMENT = "document"
LINK = "link"
VIDEO = "video"
IMAGE = "image"
WORKSHEET = "worksheet"
PRESENTATION = "presentation"
OTHER = "other"
class PhaseMaterialDB(Base):
"""
Phasen-Materialien (Feature f19).
Ermoeglicht das Anhaengen von Dokumenten und Links an Unterrichtsphasen.
"""
__tablename__ = 'phase_materials'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
teacher_id = Column(String(100), nullable=False, index=True)
# Material-Details
title = Column(String(300), nullable=False)
material_type = Column(
SQLEnum(MaterialTypeEnum),
default=MaterialTypeEnum.DOCUMENT,
nullable=False
)
url = Column(String(2000), nullable=True) # URL oder Dateipfad
description = Column(Text, default="")
# Phasen-Zuordnung
phase = Column(String(50), nullable=True, index=True) # einstieg, erarbeitung, etc.
subject = Column(String(100), default="")
grade_level = Column(String(50), default="")
# Tags als JSON Array
tags = Column(JSON, default=list)
# Sharing und Nutzung
is_public = Column(Boolean, default=False)
usage_count = Column(Integer, default=0)
# Optionale Session-Verknuepfung
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<PhaseMaterial {self.title} ({self.material_type.value})>"
class LessonReflectionDB(Base):
"""
Post-Lesson Reflection (Phase 5: Analytics).
Ermoeglicht Lehrern, nach der Stunde Reflexionsnotizen zu speichern.
"""
__tablename__ = 'lesson_reflections'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
session_id = Column(String(36), ForeignKey('lesson_sessions.id'), nullable=False, unique=True, index=True)
teacher_id = Column(String(100), nullable=False, index=True)
# Reflexions-Inhalt
notes = Column(Text, default="")
# Optionale Selbst-Bewertung (1-5)
overall_rating = Column(Integer, nullable=True)
# Was hat gut funktioniert? (JSON Array)
what_worked = Column(JSON, default=list)
# Was wuerde ich anders machen? (JSON Array)
improvements = Column(JSON, default=list)
# Notizen fuer naechste Stunde
notes_for_next_lesson = Column(Text, default="")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<LessonReflection session={self.session_id}>"
class FeedbackTypeEnum(str, enum.Enum):
"""Feedback-Typen fuer Lehrer-Rueckmeldungen."""
BUG = "bug"
FEATURE_REQUEST = "feature_request"
IMPROVEMENT = "improvement"
PRAISE = "praise"
QUESTION = "question"
class FeedbackStatusEnum(str, enum.Enum):
"""Status eines Feedbacks."""
NEW = "new"
ACKNOWLEDGED = "acknowledged"
PLANNED = "planned"
IMPLEMENTED = "implemented"
DECLINED = "declined"
class FeedbackPriorityEnum(str, enum.Enum):
"""Prioritaet eines Feedbacks."""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class TeacherFeedbackDB(Base):
"""
Lehrer-Feedback zum Companion-Modul (Phase 7).
Ermoeglicht Lehrern, Bug-Reports, Feature-Requests und Verbesserungen
direkt aus dem Lehrer-Frontend zu senden.
"""
__tablename__ = 'teacher_feedback'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# Wer hat das Feedback gegeben?
teacher_id = Column(String(100), nullable=False, index=True)
teacher_name = Column(String(200), default="")
teacher_email = Column(String(200), default="")
# Feedback-Inhalt
title = Column(String(500), nullable=False)
description = Column(Text, nullable=False)
# Kategorisierung
feedback_type = Column(
SQLEnum(FeedbackTypeEnum),
default=FeedbackTypeEnum.IMPROVEMENT,
nullable=False
)
priority = Column(
SQLEnum(FeedbackPriorityEnum),
default=FeedbackPriorityEnum.MEDIUM,
nullable=False
)
status = Column(
SQLEnum(FeedbackStatusEnum),
default=FeedbackStatusEnum.NEW,
nullable=False,
index=True
)
# Optionale Verknuepfung zu Feature
related_feature = Column(String(50), nullable=True)
# Kontext: Wo war der User als er Feedback gab?
context_url = Column(String(500), default="")
context_phase = Column(String(50), default="") # z.B. "einstieg"
context_session_id = Column(String(36), nullable=True)
# Browser/Device Info
user_agent = Column(String(500), default="")
# Entwickler-Antwort
response = Column(Text, default="")
responded_at = Column(DateTime, nullable=True)
responded_by = Column(String(100), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<TeacherFeedback {self.id[:8]} - {self.title[:30]}>"

View File

@@ -0,0 +1,222 @@
"""
Finite State Machine fuer Unterrichtsphasen.
Definiert die erlaubten Phasen-Uebergaenge und fuehrt Transitionen durch.
"""
from datetime import datetime
from typing import Optional, List, Dict
from .models import LessonPhase, LessonSession, LESSON_PHASES
class LessonStateMachine:
"""
Finite State Machine fuer die Unterrichtsphasen.
Erlaubte Uebergaenge:
NOT_STARTED -> EINSTIEG -> ERARBEITUNG -> SICHERUNG -> TRANSFER -> REFLEXION -> ENDED
"""
# Definiert erlaubte Uebergaenge: von_phase -> [moeglich_zu_phasen]
TRANSITIONS: Dict[LessonPhase, List[LessonPhase]] = {
LessonPhase.NOT_STARTED: [LessonPhase.EINSTIEG],
LessonPhase.EINSTIEG: [LessonPhase.ERARBEITUNG],
LessonPhase.ERARBEITUNG: [LessonPhase.SICHERUNG],
LessonPhase.SICHERUNG: [LessonPhase.TRANSFER],
LessonPhase.TRANSFER: [LessonPhase.REFLEXION],
LessonPhase.REFLEXION: [LessonPhase.ENDED],
LessonPhase.ENDED: [], # Terminal State
}
# Phasen-Reihenfolge fuer Timeline
PHASE_ORDER: List[LessonPhase] = [
LessonPhase.EINSTIEG,
LessonPhase.ERARBEITUNG,
LessonPhase.SICHERUNG,
LessonPhase.TRANSFER,
LessonPhase.REFLEXION,
]
def can_transition(self, from_phase: LessonPhase, to_phase: LessonPhase) -> bool:
"""
Prueft ob ein Uebergang von from_phase zu to_phase erlaubt ist.
Args:
from_phase: Ausgangsphase
to_phase: Zielphase
Returns:
True wenn Uebergang erlaubt, False sonst
"""
allowed_targets = self.TRANSITIONS.get(from_phase, [])
return to_phase in allowed_targets
def next_phase(self, current: LessonPhase) -> Optional[LessonPhase]:
"""
Gibt die naechste Phase zurueck (oder None wenn keine vorhanden).
Args:
current: Aktuelle Phase
Returns:
Naechste Phase oder None
"""
transitions = self.TRANSITIONS.get(current, [])
return transitions[0] if transitions else None
def previous_phase(self, current: LessonPhase) -> Optional[LessonPhase]:
"""
Gibt die vorherige Phase zurueck (oder None wenn keine vorhanden).
Args:
current: Aktuelle Phase
Returns:
Vorherige Phase oder None
"""
if current == LessonPhase.NOT_STARTED or current == LessonPhase.ENDED:
return None
try:
idx = self.PHASE_ORDER.index(current)
if idx > 0:
return self.PHASE_ORDER[idx - 1]
except ValueError:
pass
return None
def transition(self, session: LessonSession, to_phase: LessonPhase) -> LessonSession:
"""
Fuehrt einen Phasen-Uebergang durch.
Args:
session: Die aktuelle Session
to_phase: Zielphase
Returns:
Aktualisierte Session
Raises:
ValueError: Wenn Uebergang nicht erlaubt
"""
if not self.can_transition(session.current_phase, to_phase):
raise ValueError(
f"Ungueltiger Uebergang: {session.current_phase.value} -> {to_phase.value}. "
f"Erlaubte Ziele: {[p.value for p in self.TRANSITIONS.get(session.current_phase, [])]}"
)
now = datetime.utcnow()
# Historie der aktuellen Phase speichern (wenn nicht NOT_STARTED)
if session.phase_started_at and session.current_phase != LessonPhase.NOT_STARTED:
duration_seconds = (now - session.phase_started_at).total_seconds()
session.phase_history.append({
"phase": session.current_phase.value,
"started_at": session.phase_started_at.isoformat(),
"ended_at": now.isoformat(),
"duration_seconds": int(duration_seconds),
})
# State aktualisieren
session.current_phase = to_phase
session.phase_started_at = now
# Spezielle Behandlung fuer Start und Ende
if to_phase == LessonPhase.EINSTIEG:
session.lesson_started_at = now
elif to_phase == LessonPhase.ENDED:
session.lesson_ended_at = now
session.phase_started_at = None # Beendete Stunde hat keinen aktiven Timer
return session
def get_phase_index(self, phase: LessonPhase) -> int:
"""
Gibt den Index der Phase in der Reihenfolge zurueck.
Args:
phase: Die Phase
Returns:
Index (0-4) oder -1 fuer NOT_STARTED/ENDED
"""
if phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
return -1
try:
return self.PHASE_ORDER.index(phase)
except ValueError:
return -1
def get_phases_info(self, session: LessonSession) -> List[Dict]:
"""
Gibt Informationen ueber alle Phasen fuer die Timeline zurueck.
Args:
session: Die aktuelle Session
Returns:
Liste mit Phasen-Informationen
"""
current_index = self.get_phase_index(session.current_phase)
phases_info = []
for i, phase in enumerate(self.PHASE_ORDER):
phase_id = phase.value
phase_config = LESSON_PHASES.get(phase_id, {})
phases_info.append({
"phase": phase_id,
"display_name": phase_config.get("display_name", phase_id.capitalize()),
"icon": phase_config.get("icon", "circle"),
"duration_minutes": session.phase_durations.get(
phase_id, phase_config.get("default_duration_minutes", 10)
),
"is_completed": i < current_index if current_index >= 0 else session.current_phase == LessonPhase.ENDED,
"is_current": i == current_index,
"is_future": i > current_index if current_index >= 0 else session.current_phase == LessonPhase.NOT_STARTED,
})
return phases_info
def is_lesson_active(self, session: LessonSession) -> bool:
"""
Prueft ob die Stunde gerade aktiv ist.
Args:
session: Die Session
Returns:
True wenn aktiv, False sonst
"""
return session.current_phase not in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]
def is_lesson_ended(self, session: LessonSession) -> bool:
"""
Prueft ob die Stunde beendet ist.
Args:
session: Die Session
Returns:
True wenn beendet, False sonst
"""
return session.current_phase == LessonPhase.ENDED
def get_total_elapsed_seconds(self, session: LessonSession) -> int:
"""
Berechnet die gesamte verstrichene Zeit seit Stundenbeginn.
Args:
session: Die Session
Returns:
Verstrichene Sekunden oder 0 wenn nicht gestartet
"""
if not session.lesson_started_at:
return 0
end_time = session.lesson_ended_at or datetime.utcnow()
return int((end_time - session.lesson_started_at).total_seconds())

View File

@@ -0,0 +1,407 @@
"""
Datenmodelle fuer die Classroom State Machine.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
class LessonPhase(str, Enum):
"""Unterrichtsphasen als Enum."""
NOT_STARTED = "not_started"
EINSTIEG = "einstieg"
ERARBEITUNG = "erarbeitung"
SICHERUNG = "sicherung"
TRANSFER = "transfer"
REFLEXION = "reflexion"
ENDED = "ended"
@dataclass
class PhaseConfig:
"""Konfiguration einer einzelnen Phase."""
phase: LessonPhase
display_name: str
duration_minutes: int
activities: List[str]
icon: str
description: str = ""
# Phasen-Definitionen mit Default-Werten
LESSON_PHASES: Dict[str, Dict[str, Any]] = {
"einstieg": {
"display_name": "Einstieg",
"default_duration_minutes": 8,
"next_phase": "erarbeitung",
"activities": ["warmup", "motivation", "problem_introduction"],
"icon": "play_circle",
"description": "Motivation und Problemstellung"
},
"erarbeitung": {
"display_name": "Erarbeitung",
"default_duration_minutes": 20,
"next_phase": "sicherung",
"activities": ["instruction", "individual_work", "partner_work", "group_work"],
"icon": "edit",
"description": "Hauptarbeitsphase mit Input und Uebungen"
},
"sicherung": {
"display_name": "Sicherung",
"default_duration_minutes": 10,
"next_phase": "transfer",
"activities": ["summary", "visualization", "documentation"],
"icon": "save",
"description": "Ergebnisse festhalten und zusammenfassen"
},
"transfer": {
"display_name": "Transfer",
"default_duration_minutes": 7,
"next_phase": "reflexion",
"activities": ["application", "real_world_connection", "differentiation"],
"icon": "compare_arrows",
"description": "Anwendung auf neue Kontexte"
},
"reflexion": {
"display_name": "Reflexion",
"default_duration_minutes": 5,
"next_phase": None,
"activities": ["feedback", "homework", "preview"],
"icon": "lightbulb",
"description": "Rueckblick, Hausaufgaben und Ausblick"
}
}
def get_default_durations() -> Dict[str, int]:
"""Gibt die Standard-Phasendauern zurueck."""
return {
phase_id: config["default_duration_minutes"]
for phase_id, config in LESSON_PHASES.items()
}
@dataclass
class LessonSession:
"""Repräsentiert eine laufende Unterrichtsstunde."""
session_id: str
teacher_id: str
class_id: str
subject: str
topic: Optional[str] = None
# State
current_phase: LessonPhase = LessonPhase.NOT_STARTED
phase_started_at: Optional[datetime] = None
lesson_started_at: Optional[datetime] = None
lesson_ended_at: Optional[datetime] = None
# Pause state (Feature f26/f27)
is_paused: bool = False
pause_started_at: Optional[datetime] = None
total_paused_seconds: int = 0 # Cumulative pause time for current phase
# Phase durations (customizable per session)
phase_durations: Dict[str, int] = field(default_factory=get_default_durations)
# History
phase_history: List[Dict[str, Any]] = field(default_factory=list)
# Metadata
notes: str = ""
homework: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert die Session in ein Dictionary."""
return {
"session_id": self.session_id,
"teacher_id": self.teacher_id,
"class_id": self.class_id,
"subject": self.subject,
"topic": self.topic,
"current_phase": self.current_phase.value,
"phase_started_at": self.phase_started_at.isoformat() if self.phase_started_at else None,
"lesson_started_at": self.lesson_started_at.isoformat() if self.lesson_started_at else None,
"lesson_ended_at": self.lesson_ended_at.isoformat() if self.lesson_ended_at else None,
"is_paused": self.is_paused,
"pause_started_at": self.pause_started_at.isoformat() if self.pause_started_at else None,
"total_paused_seconds": self.total_paused_seconds,
"phase_durations": self.phase_durations,
"phase_history": self.phase_history,
"notes": self.notes,
"homework": self.homework,
}
def get_phase_display_name(self) -> str:
"""Gibt den Anzeigenamen der aktuellen Phase zurueck."""
if self.current_phase == LessonPhase.NOT_STARTED:
return "Nicht gestartet"
elif self.current_phase == LessonPhase.ENDED:
return "Beendet"
else:
return LESSON_PHASES.get(self.current_phase.value, {}).get("display_name", "Unbekannt")
def get_total_duration_minutes(self) -> int:
"""Gibt die Gesamtdauer aller Phasen in Minuten zurueck."""
return sum(self.phase_durations.values())
@dataclass
class LessonTemplate:
"""
Vorlage fuer Unterrichtsstunden (Feature f37).
Ermoeglicht Lehrern, haeufig genutzte Stundenkonfigurationen zu speichern.
"""
template_id: str
teacher_id: str # Ersteller der Vorlage
name: str
description: str = ""
subject: str = ""
grade_level: str = "" # z.B. "7", "10", "Oberstufe"
# Phasenkonfiguration
phase_durations: Dict[str, int] = field(default_factory=get_default_durations)
# Optionale Vorbelegungen
default_topic: str = ""
default_notes: str = ""
# Metadaten
is_public: bool = False # Oeffentlich fuer alle Lehrer?
usage_count: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert das Template in ein Dictionary."""
return {
"template_id": self.template_id,
"teacher_id": self.teacher_id,
"name": self.name,
"description": self.description,
"subject": self.subject,
"grade_level": self.grade_level,
"phase_durations": self.phase_durations,
"default_topic": self.default_topic,
"default_notes": self.default_notes,
"is_public": self.is_public,
"usage_count": self.usage_count,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"total_duration_minutes": sum(self.phase_durations.values()),
}
# Vordefinierte System-Templates
SYSTEM_TEMPLATES: List[Dict[str, Any]] = [
{
"template_id": "system_standard_45",
"name": "Standard 45 Min",
"description": "Klassische Unterrichtsstunde mit 45 Minuten",
"phase_durations": {
"einstieg": 5,
"erarbeitung": 20,
"sicherung": 10,
"transfer": 5,
"reflexion": 5,
},
"is_public": True,
},
{
"template_id": "system_standard_90",
"name": "Doppelstunde 90 Min",
"description": "Ausfuehrliche Doppelstunde mit mehr Zeit fuer Erarbeitung",
"phase_durations": {
"einstieg": 10,
"erarbeitung": 40,
"sicherung": 20,
"transfer": 10,
"reflexion": 10,
},
"is_public": True,
},
{
"template_id": "system_workshop",
"name": "Workshop-Stil",
"description": "Praxisorientiert mit langer Erarbeitungsphase",
"phase_durations": {
"einstieg": 5,
"erarbeitung": 30,
"sicherung": 5,
"transfer": 5,
"reflexion": 5,
},
"is_public": True,
},
{
"template_id": "system_discussion",
"name": "Diskussion & Reflexion",
"description": "Fuer Themen mit viel Diskussionsbedarf",
"phase_durations": {
"einstieg": 8,
"erarbeitung": 15,
"sicherung": 7,
"transfer": 10,
"reflexion": 10,
},
"is_public": True,
},
{
"template_id": "system_test_prep",
"name": "Pruefungsvorbereitung",
"description": "Kompakte Wiederholung vor Tests",
"phase_durations": {
"einstieg": 3,
"erarbeitung": 25,
"sicherung": 12,
"transfer": 3,
"reflexion": 2,
},
"is_public": True,
},
]
@dataclass
class PhaseSuggestion:
"""Ein Vorschlag fuer eine Aktivitaet in einer Phase (Feature f18)."""
id: str
title: str
description: str
activity_type: str # warmup, instruction, exercise, etc.
estimated_minutes: int
icon: str
content_url: Optional[str] = None # Link to learning unit
subjects: Optional[List[str]] = None # Faecher fuer die der Vorschlag passt (None = alle)
grade_levels: Optional[List[str]] = None # Klassenstufen (None = alle)
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert den Vorschlag in ein Dictionary."""
return {
"id": self.id,
"title": self.title,
"description": self.description,
"activity_type": self.activity_type,
"estimated_minutes": self.estimated_minutes,
"icon": self.icon,
"content_url": self.content_url,
"subjects": self.subjects,
"grade_levels": self.grade_levels,
}
# ==================== Homework Tracker (Feature f20) ====================
class HomeworkStatus(Enum):
"""Status einer Hausaufgabe."""
ASSIGNED = "assigned" # Aufgegeben
IN_PROGRESS = "in_progress" # In Bearbeitung
COMPLETED = "completed" # Erledigt
OVERDUE = "overdue" # Ueberfaellig
@dataclass
class Homework:
"""
Eine Hausaufgabe (Feature f20).
Ermoeglicht das Tracking von Hausaufgaben ueber Sessions hinweg.
"""
homework_id: str
teacher_id: str
class_id: str
subject: str
title: str
description: str = ""
session_id: Optional[str] = None # Verknuepfte Session
due_date: Optional[datetime] = None
status: HomeworkStatus = HomeworkStatus.ASSIGNED
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert die Hausaufgabe in ein Dictionary."""
return {
"homework_id": self.homework_id,
"teacher_id": self.teacher_id,
"class_id": self.class_id,
"subject": self.subject,
"title": self.title,
"description": self.description,
"session_id": self.session_id,
"due_date": self.due_date.isoformat() if self.due_date else None,
"status": self.status.value,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"is_overdue": self.is_overdue,
}
@property
def is_overdue(self) -> bool:
"""Prueft ob die Hausaufgabe ueberfaellig ist."""
if not self.due_date:
return False
if self.status == HomeworkStatus.COMPLETED:
return False
return datetime.now() > self.due_date
# ==================== Phase Materials (Feature f19) ====================
class MaterialType(Enum):
"""Typ des Materials."""
DOCUMENT = "document" # PDF, Word, etc.
LINK = "link" # URL
VIDEO = "video" # Video-Link
IMAGE = "image" # Bild
WORKSHEET = "worksheet" # Arbeitsblatt
PRESENTATION = "presentation" # Praesentation
OTHER = "other"
@dataclass
class PhaseMaterial:
"""
Material fuer eine Unterrichtsphase (Feature f19).
Ermoeglicht das Anhaengen von Dokumenten, Links und Medien
an bestimmte Phasen einer Unterrichtsstunde.
"""
material_id: str
teacher_id: str
title: str
material_type: MaterialType = MaterialType.DOCUMENT
url: Optional[str] = None # URL oder Dateipfad
description: str = ""
phase: Optional[str] = None # Phasen-ID (einstieg, erarbeitung, etc.)
subject: str = ""
grade_level: str = ""
tags: List[str] = field(default_factory=list)
is_public: bool = False # Fuer Sharing mit anderen Lehrern
usage_count: int = 0
session_id: Optional[str] = None # Verknuepfung mit einer Session
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert das Material in ein Dictionary."""
return {
"material_id": self.material_id,
"teacher_id": self.teacher_id,
"title": self.title,
"material_type": self.material_type.value,
"url": self.url,
"description": self.description,
"phase": self.phase,
"subject": self.subject,
"grade_level": self.grade_level,
"tags": self.tags,
"is_public": self.is_public,
"usage_count": self.usage_count,
"session_id": self.session_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,668 @@
"""
Phasenspezifische Content-Vorschlaege (Feature f18 erweitert).
Generiert Aktivitaets-Vorschlaege basierend auf der aktuellen Unterrichtsphase
und optional dem Fach.
"""
from typing import List, Dict, Any, Optional
from .models import LessonPhase, LessonSession, PhaseSuggestion
# Unterstuetzte Faecher fuer fachspezifische Vorschlaege
SUPPORTED_SUBJECTS = [
"mathematik", "mathe", "math",
"deutsch",
"englisch", "english",
"biologie", "bio",
"physik",
"chemie",
"geschichte",
"geografie", "erdkunde",
"kunst",
"musik",
"sport",
"informatik",
]
# Fachspezifische Vorschlaege (Feature f18)
SUBJECT_SUGGESTIONS: Dict[str, Dict[LessonPhase, List[Dict[str, Any]]]] = {
"mathematik": {
LessonPhase.EINSTIEG: [
{
"id": "math_warm_up",
"title": "Kopfrechnen-Challenge",
"description": "5 schnelle Kopfrechenaufgaben zum Aufwaermen",
"activity_type": "warmup",
"estimated_minutes": 3,
"icon": "calculate",
"subjects": ["mathematik", "mathe"],
},
{
"id": "math_puzzle",
"title": "Mathematisches Raetsel",
"description": "Ein kniffliges Zahlenraetsel als Einstieg",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "extension",
"subjects": ["mathematik", "mathe"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "math_geogebra",
"title": "GeoGebra-Exploration",
"description": "Interaktive Visualisierung mit GeoGebra",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "functions",
"subjects": ["mathematik", "mathe"],
},
{
"id": "math_peer_explain",
"title": "Rechenweg erklaeren",
"description": "Schueler erklaeren sich gegenseitig ihre Loesungswege",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "groups",
"subjects": ["mathematik", "mathe"],
},
],
LessonPhase.SICHERUNG: [
{
"id": "math_formula_card",
"title": "Formelkarte erstellen",
"description": "Wichtigste Formeln auf einer Karte festhalten",
"activity_type": "documentation",
"estimated_minutes": 5,
"icon": "note_alt",
"subjects": ["mathematik", "mathe"],
},
],
},
"deutsch": {
LessonPhase.EINSTIEG: [
{
"id": "deutsch_wordle",
"title": "Wordle-Variante",
"description": "Wort des Tages erraten",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "abc",
"subjects": ["deutsch"],
},
{
"id": "deutsch_zitat",
"title": "Zitat-Interpretation",
"description": "Ein literarisches Zitat gemeinsam deuten",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "format_quote",
"subjects": ["deutsch"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "deutsch_textarbeit",
"title": "Textanalyse in Gruppen",
"description": "Gruppenarbeit zu verschiedenen Textabschnitten",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "menu_book",
"subjects": ["deutsch"],
},
{
"id": "deutsch_schreibworkshop",
"title": "Schreibwerkstatt",
"description": "Kreatives Schreiben mit Peer-Feedback",
"activity_type": "individual_work",
"estimated_minutes": 20,
"icon": "edit_note",
"subjects": ["deutsch"],
},
],
LessonPhase.SICHERUNG: [
{
"id": "deutsch_zusammenfassung",
"title": "Text-Zusammenfassung",
"description": "Die wichtigsten Punkte in 3 Saetzen formulieren",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "summarize",
"subjects": ["deutsch"],
},
],
},
"englisch": {
LessonPhase.EINSTIEG: [
{
"id": "english_smalltalk",
"title": "Small Talk Warm-Up",
"description": "2-Minuten Gespraeche zu einem Alltagsthema",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "chat",
"subjects": ["englisch", "english"],
},
{
"id": "english_video",
"title": "Authentic Video Clip",
"description": "Kurzer Clip aus einer englischen Serie oder Nachricht",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "movie",
"subjects": ["englisch", "english"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "english_role_play",
"title": "Role Play Activity",
"description": "Dialoguebung in authentischen Situationen",
"activity_type": "partner_work",
"estimated_minutes": 12,
"icon": "theater_comedy",
"subjects": ["englisch", "english"],
},
{
"id": "english_reading_circle",
"title": "Reading Circle",
"description": "Gemeinsames Lesen mit verteilten Rollen",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "auto_stories",
"subjects": ["englisch", "english"],
},
],
},
"biologie": {
LessonPhase.EINSTIEG: [
{
"id": "bio_nature_question",
"title": "Naturfrage",
"description": "Eine spannende Frage aus der Natur diskutieren",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "eco",
"subjects": ["biologie", "bio"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "bio_experiment",
"title": "Mini-Experiment",
"description": "Einfaches Experiment zum Thema durchfuehren",
"activity_type": "group_work",
"estimated_minutes": 20,
"icon": "science",
"subjects": ["biologie", "bio"],
},
{
"id": "bio_diagram",
"title": "Biologische Zeichnung",
"description": "Beschriftete Zeichnung eines Organismus",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "draw",
"subjects": ["biologie", "bio"],
},
],
},
"physik": {
LessonPhase.EINSTIEG: [
{
"id": "physik_demo",
"title": "Phaenomen-Demo",
"description": "Ein physikalisches Phaenomen vorfuehren",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "bolt",
"subjects": ["physik"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "physik_simulation",
"title": "PhET-Simulation",
"description": "Interaktive Simulation von phet.colorado.edu",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "smart_toy",
"subjects": ["physik"],
},
{
"id": "physik_rechnung",
"title": "Physikalische Rechnung",
"description": "Rechenaufgabe mit physikalischem Kontext",
"activity_type": "partner_work",
"estimated_minutes": 12,
"icon": "calculate",
"subjects": ["physik"],
},
],
},
"informatik": {
LessonPhase.EINSTIEG: [
{
"id": "info_code_puzzle",
"title": "Code-Puzzle",
"description": "Kurzen Code-Schnipsel analysieren - was macht er?",
"activity_type": "warmup",
"estimated_minutes": 4,
"icon": "code",
"subjects": ["informatik"],
},
],
LessonPhase.ERARBEITUNG: [
{
"id": "info_live_coding",
"title": "Live Coding",
"description": "Gemeinsam Code entwickeln mit Erklaerungen",
"activity_type": "instruction",
"estimated_minutes": 15,
"icon": "terminal",
"subjects": ["informatik"],
},
{
"id": "info_pair_programming",
"title": "Pair Programming",
"description": "Zu zweit programmieren - Driver und Navigator",
"activity_type": "partner_work",
"estimated_minutes": 20,
"icon": "computer",
"subjects": ["informatik"],
},
],
},
}
# Vordefinierte allgemeine Vorschlaege pro Phase
PHASE_SUGGESTIONS: Dict[LessonPhase, List[Dict[str, Any]]] = {
LessonPhase.EINSTIEG: [
{
"id": "warmup_quiz",
"title": "Kurzes Quiz zum Einstieg",
"description": "Aktivieren Sie das Vorwissen der Schueler mit 3-5 Fragen zum Thema",
"activity_type": "warmup",
"estimated_minutes": 3,
"icon": "quiz"
},
{
"id": "problem_story",
"title": "Problemgeschichte erzaehlen",
"description": "Stellen Sie ein alltagsnahes Problem vor, das zum Thema fuehrt",
"activity_type": "motivation",
"estimated_minutes": 5,
"icon": "auto_stories"
},
{
"id": "video_intro",
"title": "Kurzes Erklaervideo",
"description": "Zeigen Sie ein 2-3 Minuten Video zur Einfuehrung ins Thema",
"activity_type": "motivation",
"estimated_minutes": 4,
"icon": "play_circle"
},
{
"id": "brainstorming",
"title": "Brainstorming",
"description": "Sammeln Sie Ideen und Vorkenntnisse der Schueler an der Tafel",
"activity_type": "warmup",
"estimated_minutes": 5,
"icon": "psychology"
},
{
"id": "daily_challenge",
"title": "Tagesaufgabe vorstellen",
"description": "Praesentieren Sie die zentrale Frage oder Aufgabe der Stunde",
"activity_type": "problem_introduction",
"estimated_minutes": 3,
"icon": "flag"
}
],
LessonPhase.ERARBEITUNG: [
{
"id": "think_pair_share",
"title": "Think-Pair-Share",
"description": "Schueler denken erst einzeln nach, tauschen sich dann zu zweit aus und praesentieren im Plenum",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "groups"
},
{
"id": "worksheet_digital",
"title": "Digitales Arbeitsblatt",
"description": "Schueler bearbeiten ein interaktives Arbeitsblatt am Tablet oder Computer",
"activity_type": "individual_work",
"estimated_minutes": 15,
"icon": "description"
},
{
"id": "station_learning",
"title": "Stationenlernen",
"description": "Verschiedene Stationen mit unterschiedlichen Aufgaben und Materialien",
"activity_type": "group_work",
"estimated_minutes": 20,
"icon": "hub"
},
{
"id": "expert_puzzle",
"title": "Expertenrunde (Jigsaw)",
"description": "Schueler werden Experten fuer ein Teilthema und lehren es anderen",
"activity_type": "group_work",
"estimated_minutes": 15,
"icon": "extension"
},
{
"id": "guided_instruction",
"title": "Geleitete Instruktion",
"description": "Schrittweise Erklaerung mit Uebungsphasen zwischendurch",
"activity_type": "instruction",
"estimated_minutes": 12,
"icon": "school"
},
{
"id": "pair_programming",
"title": "Partnerarbeit",
"description": "Zwei Schueler loesen gemeinsam eine Aufgabe",
"activity_type": "partner_work",
"estimated_minutes": 10,
"icon": "people"
}
],
LessonPhase.SICHERUNG: [
{
"id": "mindmap_class",
"title": "Gemeinsame Mindmap",
"description": "Ergebnisse als Mindmap an der Tafel oder digital sammeln und strukturieren",
"activity_type": "visualization",
"estimated_minutes": 8,
"icon": "account_tree"
},
{
"id": "exit_ticket",
"title": "Exit Ticket",
"description": "Schueler notieren 3 Dinge die sie gelernt haben und 1 offene Frage",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "sticky_note_2"
},
{
"id": "gallery_walk",
"title": "Galerie-Rundgang",
"description": "Schueler praesentieren ihre Ergebnisse und geben sich Feedback",
"activity_type": "presentation",
"estimated_minutes": 10,
"icon": "photo_library"
},
{
"id": "key_points",
"title": "Kernpunkte zusammenfassen",
"description": "Gemeinsam die wichtigsten Erkenntnisse der Stunde formulieren",
"activity_type": "summary",
"estimated_minutes": 5,
"icon": "format_list_bulleted"
},
{
"id": "quick_check",
"title": "Schneller Wissenscheck",
"description": "5 kurze Fragen zur Ueberpruefung des Verstaendnisses",
"activity_type": "documentation",
"estimated_minutes": 5,
"icon": "fact_check"
}
],
LessonPhase.TRANSFER: [
{
"id": "real_world_example",
"title": "Alltagsbeispiele finden",
"description": "Schueler suchen Beispiele aus ihrem Alltag, wo das Gelernte vorkommt",
"activity_type": "application",
"estimated_minutes": 5,
"icon": "public"
},
{
"id": "challenge_task",
"title": "Knobelaufgabe",
"description": "Eine anspruchsvollere Aufgabe fuer schnelle Schueler oder als Bonus",
"activity_type": "differentiation",
"estimated_minutes": 7,
"icon": "psychology"
},
{
"id": "creative_application",
"title": "Kreative Anwendung",
"description": "Schueler wenden das Gelernte in einem kreativen Projekt an",
"activity_type": "application",
"estimated_minutes": 10,
"icon": "palette"
},
{
"id": "peer_teaching",
"title": "Peer-Teaching",
"description": "Schueler erklaeren sich gegenseitig das Gelernte",
"activity_type": "real_world_connection",
"estimated_minutes": 5,
"icon": "supervisor_account"
}
],
LessonPhase.REFLEXION: [
{
"id": "thumbs_feedback",
"title": "Daumen-Feedback",
"description": "Schnelle Stimmungsabfrage: Daumen hoch/mitte/runter",
"activity_type": "feedback",
"estimated_minutes": 2,
"icon": "thumb_up"
},
{
"id": "homework_assign",
"title": "Hausaufgabe vergeben",
"description": "Passende Hausaufgabe zur Vertiefung des Gelernten",
"activity_type": "homework",
"estimated_minutes": 3,
"icon": "home_work"
},
{
"id": "one_word",
"title": "Ein-Wort-Reflexion",
"description": "Jeder Schueler nennt ein Wort, das die Stunde beschreibt",
"activity_type": "feedback",
"estimated_minutes": 3,
"icon": "chat"
},
{
"id": "preview_next",
"title": "Ausblick naechste Stunde",
"description": "Kurzer Ausblick auf das Thema der naechsten Stunde",
"activity_type": "preview",
"estimated_minutes": 2,
"icon": "event"
},
{
"id": "learning_log",
"title": "Lerntagebuch",
"description": "Schueler notieren ihre wichtigsten Erkenntnisse im Lerntagebuch",
"activity_type": "feedback",
"estimated_minutes": 4,
"icon": "menu_book"
}
]
}
class SuggestionEngine:
"""
Engine zur Generierung von phasenspezifischen Vorschlaegen (Feature f18 erweitert).
Liefert Aktivitaets-Vorschlaege basierend auf:
- Aktueller Phase
- Fach (priorisiert fachspezifische Vorschlaege)
- Optional: Klassenstufe, bisherige Nutzung
"""
def _normalize_subject(self, subject: str) -> Optional[str]:
"""Normalisiert Fachnamen fuer die Suche."""
if not subject:
return None
normalized = subject.lower().strip()
# Mapping zu Hauptkategorien
mappings = {
"mathe": "mathematik",
"math": "mathematik",
"bio": "biologie",
"english": "englisch",
"erdkunde": "geografie",
}
return mappings.get(normalized, normalized)
def _get_subject_suggestions(
self,
subject: str,
phase: LessonPhase
) -> List[Dict[str, Any]]:
"""Holt fachspezifische Vorschlaege (Feature f18)."""
normalized = self._normalize_subject(subject)
if not normalized:
return []
subject_data = SUBJECT_SUGGESTIONS.get(normalized, {})
return subject_data.get(phase, [])
def get_suggestions(
self,
session: LessonSession,
limit: int = 3
) -> List[PhaseSuggestion]:
"""
Gibt Vorschlaege fuer die aktuelle Phase zurueck.
Priorisiert fachspezifische Vorschlaege (Feature f18),
ergaenzt mit allgemeinen Vorschlaegen.
Args:
session: Die aktuelle Session
limit: Maximale Anzahl Vorschlaege
Returns:
Liste von PhaseSuggestion Objekten
"""
# Keine Vorschlaege fuer inaktive Phasen
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
return []
suggestions = []
seen_ids = set()
# 1. Fachspezifische Vorschlaege zuerst (Feature f18)
if session.subject:
subject_suggestions = self._get_subject_suggestions(
session.subject,
session.current_phase
)
for s in subject_suggestions:
if s["id"] not in seen_ids:
suggestions.append(PhaseSuggestion(**s))
seen_ids.add(s["id"])
if len(suggestions) >= limit:
return suggestions
# 2. Allgemeine Vorschlaege ergaenzen
phase_suggestions = PHASE_SUGGESTIONS.get(session.current_phase, [])
for s in phase_suggestions:
if s["id"] not in seen_ids:
suggestions.append(PhaseSuggestion(**s))
seen_ids.add(s["id"])
if len(suggestions) >= limit:
break
return suggestions
def get_all_suggestions(self, session: LessonSession) -> List[PhaseSuggestion]:
"""
Gibt alle Vorschlaege fuer die aktuelle Phase zurueck.
Inkludiert fachspezifische und allgemeine Vorschlaege (Feature f18).
Args:
session: Die aktuelle Session
Returns:
Alle Vorschlaege fuer die Phase
"""
return self.get_suggestions(session, limit=100)
def get_suggestion_by_id(
self,
session: LessonSession,
suggestion_id: str
) -> Optional[PhaseSuggestion]:
"""
Gibt einen spezifischen Vorschlag zurueck.
Args:
session: Die aktuelle Session
suggestion_id: ID des Vorschlags
Returns:
Der Vorschlag oder None
"""
all_suggestions = self.get_all_suggestions(session)
for s in all_suggestions:
if s.id == suggestion_id:
return s
return None
def get_suggestions_by_type(
self,
session: LessonSession,
activity_type: str
) -> List[PhaseSuggestion]:
"""
Gibt Vorschlaege eines bestimmten Typs zurueck.
Args:
session: Die aktuelle Session
activity_type: Der Aktivitaetstyp (z.B. "warmup", "group_work")
Returns:
Gefilterte Vorschlaege
"""
all_suggestions = self.get_all_suggestions(session)
return [s for s in all_suggestions if s.activity_type == activity_type]
def get_suggestions_response(
self,
session: LessonSession,
limit: int = 3
) -> Dict[str, Any]:
"""
Gibt die Vorschlaege als API-Response-Format zurueck (Feature f18 erweitert).
Args:
session: Die aktuelle Session
limit: Maximale Anzahl Vorschlaege
Returns:
Dictionary fuer API-Response
"""
suggestions = self.get_suggestions(session, limit)
# Zaehle fachspezifische und allgemeine Vorschlaege
general_count = len(PHASE_SUGGESTIONS.get(session.current_phase, []))
subject_count = len(self._get_subject_suggestions(
session.subject,
session.current_phase
)) if session.subject else 0
return {
"suggestions": [s.to_dict() for s in suggestions],
"current_phase": session.current_phase.value,
"phase_display_name": session.get_phase_display_name(),
"total_available": general_count + subject_count,
"subject_specific_available": subject_count,
"subject": session.subject,
}

View File

@@ -0,0 +1,272 @@
"""
Timer Service fuer Phasen-Countdown.
Berechnet verbleibende Zeit, Warnungen und Overtime-Status.
"""
from datetime import datetime
from typing import Dict, Any
from .models import LessonPhase, LessonSession
class PhaseTimer:
"""
Timer Service fuer den Phasen-Countdown.
Features:
- Berechnet verbleibende Zeit pro Phase
- Warnung bei 2 Minuten vor Ende
- Overtime-Erkennung wenn Zeit abgelaufen
"""
# Warnung X Sekunden vor Ende (2 Minuten)
WARNING_THRESHOLD_SECONDS = 120
def get_remaining_seconds(self, session: LessonSession) -> int:
"""
Berechnet die verbleibende Zeit in der aktuellen Phase.
Beruecksichtigt Pause-Zeit: Wenn pausiert, wird die Zeit seit Pause-Start
nicht mitgezaehlt. Die kumulative Pause-Zeit (total_paused_seconds) wird
von der verstrichenen Zeit abgezogen.
Args:
session: Die aktuelle Session
Returns:
Verbleibende Sekunden (min 0)
"""
# Inaktive Phasen haben keinen Timer
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
return 0
if not session.phase_started_at:
return 0
# Phasendauer holen
phase_id = session.current_phase.value
duration_minutes = session.phase_durations.get(phase_id, 10)
duration_seconds = duration_minutes * 60
# Verstrichene Zeit berechnen (mit Pause-Beruecksichtigung)
elapsed = self._get_effective_elapsed(session)
remaining = duration_seconds - elapsed
return max(0, int(remaining))
def _get_effective_elapsed(self, session: LessonSession) -> float:
"""
Berechnet die effektive verstrichene Zeit abzueglich Pausenzeit.
Args:
session: Die aktuelle Session
Returns:
Effektive verstrichene Sekunden
"""
if not session.phase_started_at:
return 0
# Basis: Zeit seit Phasenstart
total_elapsed = (datetime.utcnow() - session.phase_started_at).total_seconds()
# Abzug: Kumulative Pause-Zeit
total_elapsed -= session.total_paused_seconds
# Wenn aktuell pausiert: Zeit seit Pause-Start auch abziehen
if session.is_paused and session.pause_started_at:
current_pause = (datetime.utcnow() - session.pause_started_at).total_seconds()
total_elapsed -= current_pause
return max(0, total_elapsed)
def get_elapsed_seconds(self, session: LessonSession) -> int:
"""
Berechnet die effektive verstrichene Zeit in der aktuellen Phase.
Beruecksichtigt Pause-Zeit analog zu get_remaining_seconds.
Args:
session: Die aktuelle Session
Returns:
Verstrichene Sekunden (abzueglich Pausen)
"""
if not session.phase_started_at:
return 0
if session.current_phase == LessonPhase.ENDED:
return 0
return int(self._get_effective_elapsed(session))
def get_total_seconds(self, session: LessonSession) -> int:
"""
Gibt die Gesamtdauer der aktuellen Phase in Sekunden zurueck.
Args:
session: Die aktuelle Session
Returns:
Gesamtdauer in Sekunden
"""
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
return 0
phase_id = session.current_phase.value
duration_minutes = session.phase_durations.get(phase_id, 10)
return duration_minutes * 60
def get_percentage_remaining(self, session: LessonSession) -> int:
"""
Berechnet den Prozentsatz der verbleibenden Zeit.
Args:
session: Die aktuelle Session
Returns:
Prozent (0-100)
"""
total = self.get_total_seconds(session)
if total == 0:
return 0
remaining = self.get_remaining_seconds(session)
return round((remaining / total) * 100)
def get_percentage_elapsed(self, session: LessonSession) -> int:
"""
Berechnet den Prozentsatz der verstrichenen Zeit.
Args:
session: Die aktuelle Session
Returns:
Prozent (0-100)
"""
return 100 - self.get_percentage_remaining(session)
def is_warning(self, session: LessonSession) -> bool:
"""
Prueft ob die Warnungszeit erreicht ist (2 Min vor Ende).
Args:
session: Die aktuelle Session
Returns:
True wenn Warnung aktiv
"""
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
return False
remaining = self.get_remaining_seconds(session)
return 0 < remaining <= self.WARNING_THRESHOLD_SECONDS
def is_overtime(self, session: LessonSession) -> bool:
"""
Prueft ob die Phase ueberzogen wurde.
Args:
session: Die aktuelle Session
Returns:
True wenn Overtime
"""
if session.current_phase in [LessonPhase.NOT_STARTED, LessonPhase.ENDED]:
return False
remaining = self.get_remaining_seconds(session)
return remaining == 0
def get_overtime_seconds(self, session: LessonSession) -> int:
"""
Berechnet wie viele Sekunden die Phase ueberzogen wurde.
Args:
session: Die aktuelle Session
Returns:
Overtime-Sekunden (0 wenn nicht ueberzogen)
"""
if not self.is_overtime(session):
return 0
elapsed = self.get_elapsed_seconds(session)
total = self.get_total_seconds(session)
return max(0, elapsed - total)
def format_time(self, seconds: int) -> str:
"""
Formatiert Sekunden als MM:SS String.
Args:
seconds: Sekunden
Returns:
Formatierte Zeit (z.B. "12:34")
"""
minutes = seconds // 60
secs = seconds % 60
return f"{minutes:02d}:{secs:02d}"
def get_phase_status(self, session: LessonSession) -> Dict[str, Any]:
"""
Gibt den vollstaendigen Timer-Status zurueck.
Args:
session: Die aktuelle Session
Returns:
Dictionary mit Timer-Informationen
"""
remaining = self.get_remaining_seconds(session)
total = self.get_total_seconds(session)
elapsed = self.get_elapsed_seconds(session)
overtime = self.get_overtime_seconds(session)
return {
"remaining_seconds": remaining,
"remaining_formatted": self.format_time(remaining),
"total_seconds": total,
"total_formatted": self.format_time(total),
"elapsed_seconds": elapsed,
"elapsed_formatted": self.format_time(elapsed),
"percentage_remaining": self.get_percentage_remaining(session),
"percentage_elapsed": self.get_percentage_elapsed(session),
"percentage": self.get_percentage_remaining(session), # Alias for Visual Timer
"warning": self.is_warning(session),
"overtime": self.is_overtime(session),
"overtime_seconds": overtime,
"overtime_formatted": self.format_time(overtime) if overtime > 0 else None,
"is_paused": session.is_paused,
}
def get_lesson_timer(self, session: LessonSession) -> Dict[str, Any]:
"""
Gibt den Timer-Status fuer die gesamte Stunde zurueck.
Args:
session: Die aktuelle Session
Returns:
Dictionary mit Stunden-Timer-Informationen
"""
total_planned = session.get_total_duration_minutes() * 60
if session.lesson_started_at:
if session.lesson_ended_at:
actual_duration = (session.lesson_ended_at - session.lesson_started_at).total_seconds()
else:
actual_duration = (datetime.utcnow() - session.lesson_started_at).total_seconds()
else:
actual_duration = 0
return {
"total_planned_seconds": total_planned,
"total_planned_formatted": self.format_time(total_planned),
"actual_duration_seconds": int(actual_duration),
"actual_duration_formatted": self.format_time(int(actual_duration)),
"started_at": session.lesson_started_at.isoformat() if session.lesson_started_at else None,
"ended_at": session.lesson_ended_at.isoformat() if session.lesson_ended_at else None,
}