#!/usr/bin/env python3 """Planlagte sendinger: «send denne kl. 18:00». Dette er den første delen av appen som *må* ligge på disk. Kø og historikk har alltid bare levd i minnet, og det har gått bra fordi en melding spilles med en gang. En melding planlagt til 18:00 må overleve at containeren startes om 17:45, så den lagres i en JSON-fil som skrives atomisk. Ingen database. Dette er en håndfull meldinger på et arrangement, og en fil man kan lese med `cat` er verdt mer her enn indekser. Modulen vet ingenting om Flask eller om avspilling. Den lagrer, og den svarer på «hva skal sendes nå?». announcer.py eier tråden som spør. """ from __future__ import annotations import json import logging import secrets import threading import time from dataclasses import asdict, dataclass, field from pathlib import Path log = logging.getLogger("announcer.schedule") # Hvor lenge etter planlagt tid vi fortsatt sender. Dette er slingringsmonn # for at tråden våkner med noen sekunders mellomrom og for en kjapp omstart – # ikke en policy om å ta igjen etterslep. Var maskina av en time, skal # «turneringen starter om ti minutter» ikke ut på anlegget. DEFAULT_GRACE = 120.0 # Hvor langt fram man får planlegge. Fanger opp tastefeil i årstall. DEFAULT_MAX_AHEAD = 30 * 86400.0 # Hvor mange ferdige (sendt/hoppet over/avbrutt) vi beholder for visning. KEEP_DONE = 50 # pending -> queued (lagt i kø) -> sent | failed | stopped # -> missed (forfalt mens maskina var av) -> cancelled (avbrutt) STATUSES = ("pending", "queued", "sent", "failed", "stopped", "missed", "cancelled") @dataclass class Scheduled: id: str at: float # epoch, absolutt – derfor ingen tidssone text: str voice: str speed: float chime: str user: str = "" subject: str = "" method: str = "none" created: float = field(default_factory=time.time) status: str = "pending" # se STATUSES note: str = "" zones: list[str] = field(default_factory=list) # tom = lokalt / alle def public(self) -> dict: d = asdict(self) d.pop("subject", None) return d class Schedule: """Trådsikker liste av planlagte meldinger, lagret i én JSON-fil.""" def __init__(self, path: Path, *, grace: float = DEFAULT_GRACE, max_ahead: float = DEFAULT_MAX_AHEAD) -> None: self.path = Path(path) self.grace = float(grace) self.max_ahead = float(max_ahead) self._lock = threading.Lock() self.items: list[Scheduled] = [] self._load() # -- disk -------------------------------------------------------------- def _load(self) -> None: if not self.path.is_file(): return try: raw = json.loads(self.path.read_text(encoding="utf-8")) except (OSError, ValueError) as e: # En ødelagt fil skal ikke hindre appen fra å starte; da er det # bedre å miste planene og si det i loggen. log.warning("kunne ikke lese %s (%s) – starter med tom liste", self.path, e) return known = {f for f in Scheduled.__dataclass_fields__} for entry in raw.get("items", []): if not isinstance(entry, dict): continue try: self.items.append(Scheduled(**{k: v for k, v in entry.items() if k in known})) except TypeError as e: log.warning("hopper over ugyldig oppføring (%s)", e) log.info("leste %d planlagte sendinger fra %s", len(self.items), self.path) def _save(self) -> None: """Skriv til side og flytt på plass, så fila aldri er halvskrevet.""" try: self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_suffix(".tmp") tmp.write_text( json.dumps({"items": [asdict(i) for i in self.items]}, ensure_ascii=False, indent=1), encoding="utf-8", ) tmp.replace(self.path) except OSError as e: log.error("fikk ikke lagret %s: %s", self.path, e) def _trim(self) -> None: done = [i for i in self.items if i.status != "pending"] if len(done) > KEEP_DONE: drop = {id(i) for i in sorted(done, key=lambda i: i.at)[:len(done) - KEEP_DONE]} self.items = [i for i in self.items if id(i) not in drop] # -- endringer --------------------------------------------------------- def add(self, *, at: float, text: str, voice: str, speed: float, chime: str, user: str = "", subject: str = "", method: str = "none", zones: list[str] | None = None) -> Scheduled: """Legger til en melding. Kaster ValueError på et ubrukelig tidspunkt.""" at = float(at) now = time.time() if at < now - self.grace: raise ValueError("Tidspunktet er passert") if at > now + self.max_ahead: raise ValueError(f"Mer enn {int(self.max_ahead / 86400)} dager fram i tid") item = Scheduled( id=secrets.token_hex(4), at=at, text=text, voice=voice, speed=speed, chime=chime, user=user, subject=subject, method=method, zones=[str(z) for z in (zones or [])], ) with self._lock: self.items.append(item) self._trim() self._save() return item def cancel(self, sched_id: str) -> Scheduled | None: with self._lock: for i in self.items: if i.id == sched_id and i.status == "pending": i.status = "cancelled" self._trim() self._save() return i return None def get(self, sched_id: str) -> Scheduled | None: with self._lock: return next((i for i in self.items if i.id == sched_id), None) def mark(self, sched_id: str, status: str, note: str = "") -> None: assert status in STATUSES, status with self._lock: for i in self.items: if i.id == sched_id: i.status = status i.note = note # Også her, ikke bare i add(): ellers vokser lista av # ferdige ubegrenset så lenge ingen legger til noe nytt. self._trim() self._save() return # -- oppslag ----------------------------------------------------------- def due(self, now: float | None = None) -> tuple[list[Scheduled], list[Scheduled]]: """Returnerer (skal sendes nå, for sent – hopp over). Kaller ikke _save(); announcer.py markerer dem etterpå, så en melding ikke kan bli stående som «sendt» uten at den faktisk ble lagt i kø. """ now = time.time() if now is None else now send: list[Scheduled] = [] missed: list[Scheduled] = [] with self._lock: for i in self.items: if i.status != "pending" or i.at > now: continue (send if now - i.at <= self.grace else missed).append(i) return send, missed def snapshot(self) -> dict: with self._lock: pending = sorted((i for i in self.items if i.status == "pending"), key=lambda i: i.at) done = sorted((i for i in self.items if i.status != "pending"), key=lambda i: i.at, reverse=True)[:15] return { "pending": [i.public() for i in pending], "done": [i.public() for i in done], }