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:
272
backend/classroom_engine/timer.py
Normal file
272
backend/classroom_engine/timer.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user