Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
8.4 KiB
Python
273 lines
8.4 KiB
Python
"""
|
|
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,
|
|
}
|