datetime_trigger.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. # -*- coding: utf-8 -*-
  2. """
  3. DateTime Trigger - Einmaliger Trigger für spezifischen Zeitpunkt.
  4. """
  5. from datetime import datetime
  6. from typing import Any
  7. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  8. from trixy_core.scheduler.trigger.base import Trigger, TriggerContext, TriggerState
  9. class DateTimeTrigger(Trigger):
  10. """
  11. Trigger für einen spezifischen Zeitpunkt.
  12. Einmaliger Trigger - nach Auslösung ist er beendet.
  13. Beispiele:
  14. - DateTimeTrigger(datetime(2024, 12, 31, 23, 59)) - Silvester
  15. - DateTimeTrigger.in_minutes(30) - In 30 Minuten
  16. - DateTimeTrigger.at_time(14, 30) - Heute um 14:30
  17. """
  18. def __init__(
  19. self,
  20. run_at: datetime,
  21. tolerance_seconds: float = 60.0,
  22. name: str | None = None,
  23. ):
  24. """
  25. Initialisiert den DateTime-Trigger.
  26. Args:
  27. run_at: Zeitpunkt für Auslösung
  28. tolerance_seconds: Toleranz in Sekunden (Default: 60)
  29. name: Trigger-Name
  30. """
  31. super().__init__(name)
  32. self._run_at = run_at
  33. self._tolerance_seconds = tolerance_seconds
  34. @property
  35. def run_at(self) -> datetime:
  36. """Geplanter Zeitpunkt."""
  37. return self._run_at
  38. @property
  39. def tolerance_seconds(self) -> float:
  40. """Toleranz in Sekunden."""
  41. return self._tolerance_seconds
  42. @classmethod
  43. def in_seconds(
  44. cls,
  45. seconds: int,
  46. name: str | None = None,
  47. ) -> "DateTimeTrigger":
  48. """Erstellt Trigger für X Sekunden in der Zukunft."""
  49. from datetime import timedelta
  50. return cls(
  51. run_at=datetime.now() + timedelta(seconds=seconds),
  52. name=name,
  53. )
  54. @classmethod
  55. def in_minutes(
  56. cls,
  57. minutes: int,
  58. name: str | None = None,
  59. ) -> "DateTimeTrigger":
  60. """Erstellt Trigger für X Minuten in der Zukunft."""
  61. from datetime import timedelta
  62. return cls(
  63. run_at=datetime.now() + timedelta(minutes=minutes),
  64. name=name,
  65. )
  66. @classmethod
  67. def in_hours(
  68. cls,
  69. hours: int,
  70. name: str | None = None,
  71. ) -> "DateTimeTrigger":
  72. """Erstellt Trigger für X Stunden in der Zukunft."""
  73. from datetime import timedelta
  74. return cls(
  75. run_at=datetime.now() + timedelta(hours=hours),
  76. name=name,
  77. )
  78. @classmethod
  79. def at_time(
  80. cls,
  81. hour: int,
  82. minute: int = 0,
  83. second: int = 0,
  84. name: str | None = None,
  85. ) -> "DateTimeTrigger":
  86. """
  87. Erstellt Trigger für heute zu bestimmter Uhrzeit.
  88. Falls Uhrzeit schon vorbei, wird morgen verwendet.
  89. """
  90. from datetime import timedelta
  91. now = datetime.now()
  92. run_at = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
  93. # Wenn Zeit schon vorbei, auf morgen setzen
  94. if run_at <= now:
  95. run_at = run_at + timedelta(days=1)
  96. return cls(run_at=run_at, name=name)
  97. @classmethod
  98. def tomorrow_at(
  99. cls,
  100. hour: int,
  101. minute: int = 0,
  102. second: int = 0,
  103. name: str | None = None,
  104. ) -> "DateTimeTrigger":
  105. """Erstellt Trigger für morgen zu bestimmter Uhrzeit."""
  106. from datetime import timedelta
  107. now = datetime.now()
  108. run_at = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
  109. run_at = run_at + timedelta(days=1)
  110. return cls(run_at=run_at, name=name)
  111. def should_fire(self, context: TriggerContext) -> bool:
  112. """Prüft ob Trigger auslösen soll."""
  113. if not self.is_active:
  114. return False
  115. now = context.current_time
  116. diff = abs((now - self._run_at).total_seconds())
  117. if diff <= self._tolerance_seconds:
  118. return True
  119. # Wenn Zeitpunkt vorbei, Trigger beenden
  120. if now > self._run_at:
  121. self._state = TriggerState.FINISHED
  122. return False
  123. return False
  124. def get_next_fire_time(
  125. self,
  126. last_fire_time: datetime | None = None,
  127. ) -> datetime | None:
  128. """Berechnet nächsten Auslösezeitpunkt."""
  129. if not self.is_active:
  130. return None
  131. # Einmaliger Trigger - wenn schon gefeuert, kein nächstes Mal
  132. if last_fire_time or self._fire_count > 0:
  133. return None
  134. now = datetime.now()
  135. # Wenn Zeitpunkt in Vergangenheit
  136. if self._run_at < now:
  137. return None
  138. return self._run_at
  139. def fire(self) -> None:
  140. """Markiert als ausgelöst und beendet Trigger."""
  141. super().fire()
  142. self._state = TriggerState.FINISHED
  143. def to_dict(self) -> dict:
  144. """Konvertiert zu Dictionary."""
  145. return {
  146. **self._base_dict(),
  147. "run_at": self._run_at.isoformat(),
  148. "tolerance_seconds": self._tolerance_seconds,
  149. }
  150. @classmethod
  151. def from_dict(cls, data: dict) -> "DateTimeTrigger":
  152. """Erstellt Trigger aus Dictionary."""
  153. return cls(
  154. run_at=datetime.fromisoformat(data["run_at"]),
  155. tolerance_seconds=data.get("tolerance_seconds", 60.0),
  156. name=data.get("name"),
  157. )
  158. @classmethod
  159. def display_name(cls) -> str:
  160. """Anzeigename fuer die TUI."""
  161. return "Zeitpunkt"
  162. @classmethod
  163. def form_fields(cls) -> list[FormField]:
  164. """Gibt die Formular-Felder fuer die TUI zurueck."""
  165. return [
  166. FormField(
  167. name="run_at", label="Ausfuehrungszeitpunkt",
  168. field_type=FieldType.DATETIME, required=True,
  169. placeholder="2024-12-31T23:59:00",
  170. help_text="ISO-Format: YYYY-MM-DDTHH:MM:SS",
  171. ),
  172. FormField(
  173. name="tolerance_seconds", label="Toleranz (Sek.)",
  174. field_type=FieldType.NUMBER, default=60.0,
  175. min_value=1.0, is_float=True,
  176. ),
  177. ]
  178. @classmethod
  179. def validate_form(cls, data: dict) -> list[FormValidationError]:
  180. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  181. errors: list[FormValidationError] = []
  182. run_at = data.get("run_at", "")
  183. if not run_at:
  184. errors.append(FormValidationError("run_at", "Zeitpunkt ist erforderlich"))
  185. else:
  186. try:
  187. datetime.fromisoformat(run_at)
  188. except ValueError:
  189. errors.append(FormValidationError("run_at", "Ungueltiges Datum (ISO-Format erwartet)"))
  190. return errors
  191. def __repr__(self) -> str:
  192. return f"DateTimeTrigger(run_at={self._run_at.isoformat()})"