Planlagte sendinger
«Send denne kl. 18:00.» Engangs, ikke gjentakende. 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å schedule.py lagrer i data/scheduled.json – skrevet til side og flyttet på plass, så fila aldri er halvskrevet. Ingen database, med vilje: dette er en håndfull meldinger på et arrangement, og en fil man kan lese med cat er verdt mer her enn indekser. En ødelagt fil hindrer ikke oppstart; da er det bedre å miste planene og si det i loggen. Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på «hva skal sendes nå?». _scheduler() i announcer.py eier tråden som spør hvert 20. sekund og legger forfalte meldinger i den vanlige køen – derfra oppfører de seg nøyaktig som en melding sendt for hånd. Gikk den glipp av, spilles den ikke. due() skiller på grace_seconds (120): innenfor den sendes meldingen, utenfor markeres den missed og vises som det. Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en kjapp omstart, ikke en policy om å ta igjen etterslep – «turneringen starter om ti minutter» skal ikke ut på anlegget en time etter at maskina kom tilbake. due() endrer ingenting selv; markeringen skjer først etter at meldingen faktisk er i kø, så ingenting kan stå som «sendt» uten å ha blitt det. Klienten sender absolutt tidspunkt (epoch), så tidssoner spiller ingen rolle for selve planleggingen. TZ settes likevel fra .env, ellers står loggen i UTC mens man selv står i CEST. Planlegging er låst til samme forhåndslytt-nøkkel som «Send ut»: man planlegger bare det man har hørt. Det gir en bonus – forhåndslytt varmer cache/, så WAV-en ligger ferdig på disk, og en planlagt edge-tts-melding spiller kl. 18:00 selv om nettet er nede da. Sine egne kan man alltid avbryte. Andres krever clear, samme rett som å tømme køen, siden det er samme slags inngrep i noe andre har satt i gang. 23 nye tester. Verifisert i container: sending fyrer og spiller, overlever en omstart av containeren, og en melding som forfalt mens maskina var av blir markert missed uten å bli spilt. Merk ved oppgradering: schedule er en ny handling, og et oppsett med eksplisitt [roles] i config.toml får den ikke automatisk. Den må legges inn selv. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6c1253f54c
commit
04b3712ef5
15 changed files with 746 additions and 15 deletions
|
|
@ -16,6 +16,10 @@ XDG_RUNTIME_DIR=/run/user/1000
|
|||
BIND_IP=0.0.0.0
|
||||
ANNOUNCER_PORT=8090
|
||||
|
||||
# Tidssone for logg og visning. Planlagte sendinger lagres som absolutt
|
||||
# tid, så denne kan ikke gjøre dem feil.
|
||||
TZ=Europe/Oslo
|
||||
|
||||
# Lydutgang. Tom = systemets standard. Finn navn med: pactl list short sinks
|
||||
ANNOUNCER_SINK=
|
||||
|
||||
|
|
|
|||
49
AGENTS.md
49
AGENTS.md
|
|
@ -18,10 +18,11 @@ Filer:
|
|||
| `announcer.py` | server, kø, TTS, jingler, ruter |
|
||||
| `auth.py` | `Principal`, leverandørene, OIDC-rutene |
|
||||
| `config.py` | `config.toml` + miljøvariabler, standardverdier |
|
||||
| `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` |
|
||||
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
|
||||
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
||||
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
|
||||
| `tests/test_auth.py` | `uv run pytest` |
|
||||
| `tests/test_auth.py` + `test_schedule.py` | `uv run pytest` – 80 tester |
|
||||
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
|
||||
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
|
||||
| `compose.test.yaml` | testoppsett med dummy-IdP, se under |
|
||||
|
|
@ -66,6 +67,7 @@ jingler i `data/chimes/` uten å gå inn i containeren):
|
|||
| `data/chimes/` | egne jinglefiler |
|
||||
| `data/logs/` | revisjonsloggen |
|
||||
| `data/instance/` | sesjonsnøkkelen |
|
||||
| `data/scheduled.json` | planlagte sendinger. Lesbar med `cat` |
|
||||
| `data/config.toml` | valgfri. Uten den er sida åpen |
|
||||
|
||||
### Lyden ut av containeren
|
||||
|
|
@ -201,6 +203,46 @@ veien for å prøve en endring kjapt; `uv run pytest` for testene. Men
|
|||
det er ryddet bort. Skal noe prøves med innlogging, bruk
|
||||
`compose.test.yaml`.
|
||||
|
||||
## Planlagte sendinger
|
||||
|
||||
`schedule.py` er den første delen av appen som **må** ligge på disk. Kø og
|
||||
historikk har alltid bare levd i minnet, og det går 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 `data/scheduled.json`, skrevet til side og
|
||||
flyttet på plass så fila aldri er halvskrevet.
|
||||
|
||||
Ingen database, med vilje: 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 avspilling. Den lagrer, og den svarer på
|
||||
«hva skal sendes nå?». `_scheduler()` i `announcer.py` eier tråden som spør
|
||||
hvert 20. sekund og legger forfalte meldinger i den vanlige køen – derfra
|
||||
oppfører de seg nøyaktig som en melding sendt for hånd.
|
||||
|
||||
**Bare engangs**, ikke gjentakende. Derfor sender klienten et absolutt
|
||||
tidspunkt (epoch), og hele tidssoneproblemet forsvinner: containeren kan stå
|
||||
i UTC uten at det gjør noe. `TZ` settes likevel fra `.env`, ellers står
|
||||
loggen i UTC mens du står i CEST. Skal gjentakelse inn senere, må «09:00»
|
||||
tolkes i en tidssone, og da begynner `TZ` å bety noe.
|
||||
|
||||
**Gikk den glipp av, spilles den ikke.** `due()` skiller på `grace_seconds`
|
||||
(standard 120): innenfor den sendes meldingen, utenfor markeres den `missed`.
|
||||
Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en
|
||||
kjapp omstart – ikke en policy om å ta igjen etterslep. «Turneringen starter
|
||||
om ti minutter» skal ikke ut på anlegget en time etter at maskina kom
|
||||
tilbake. `due()` endrer ingenting selv; `announcer.py` markerer først etter at
|
||||
meldingen faktisk er lagt i kø, så ingenting kan stå som «sendt» uten å ha
|
||||
blitt det.
|
||||
|
||||
Forhåndslytten gir en bonus: planlegging er låst til den samme nøkkelen som
|
||||
«Send ut», så man planlegger bare det man har hørt – og siden forhåndslytt
|
||||
varmer `cache/`, ligger WAV-en ferdig på disk. **En planlagt edge-tts-melding
|
||||
spiller altså kl. 18:00 selv om nettet er nede da.**
|
||||
|
||||
Sine egne planlagte sendinger kan man alltid avbryte. Andres krever `clear` –
|
||||
samme rett som å tømme køen, siden det er samme slags inngrep i noe andre har
|
||||
satt i gang.
|
||||
|
||||
## Hvordan det henger sammen
|
||||
|
||||
- `/api/config` er alt sida trenger for å tegne seg selv: branding, stemmer,
|
||||
|
|
@ -262,6 +304,11 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
|
|||
- `player = Player()` står på modulnivå, så avspillingstråden starter ved
|
||||
`import announcer` – også i `install.sh`, som importerer modulen for å laste
|
||||
ned stemmer. Harmløst, men greit å vite.
|
||||
- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt
|
||||
`[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt
|
||||
konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din
|
||||
har ikke lov til dette». Legg `"schedule"` inn selv. Dette traff
|
||||
`docker/config.test.toml` under testing.
|
||||
- `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test.
|
||||
Uten den arver testene maskinens miljø – systemd-enheten setter
|
||||
`ANNOUNCER_PORT`, og compose setter JWT- og OIDC-hemmeligheter, noe som
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ FROM python:3.13-slim
|
|||
# velger selv hvilken som finnes (player_cmd()), så begge lydservere virker.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg ca-certificates pipewire-bin pulseaudio-utils \
|
||||
ffmpeg ca-certificates tzdata pipewire-bin pulseaudio-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
|
@ -25,7 +25,7 @@ COPY pyproject.toml uv.lock ./
|
|||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Koden sist, så avhengighetslaget gjenbrukes når bare appen endres.
|
||||
COPY announcer.py auth.py config.py index.html ./
|
||||
COPY announcer.py auth.py config.py schedule.py index.html ./
|
||||
|
||||
# Alt som skal overleve en ny container ligger under /data, som monteres
|
||||
# inn fra verten. Stemmene er ~670 MB og lastes ned ved første oppstart.
|
||||
|
|
@ -34,6 +34,7 @@ ENV ANNOUNCER_VOICES=/data/voices \
|
|||
ANNOUNCER_CHIMES=/data/chimes \
|
||||
ANNOUNCER_LOG=/data/logs/announcements.jsonl \
|
||||
ANNOUNCER_STATE=/data/instance \
|
||||
ANNOUNCER_SCHEDULE=/data/scheduled.json \
|
||||
ANNOUNCER_CONFIG=/data/config.toml \
|
||||
ANNOUNCER_PORT=8090
|
||||
|
||||
|
|
|
|||
104
announcer.py
104
announcer.py
|
|
@ -31,6 +31,7 @@ from flask import Flask, Response, abort, jsonify, request, send_file
|
|||
|
||||
import auth
|
||||
import config
|
||||
import schedule as sched
|
||||
from auth import require
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
|
@ -38,6 +39,7 @@ CFG = config.load()
|
|||
VOICE_DIR = CFG.path("voices")
|
||||
CACHE_DIR = CFG.path("cache")
|
||||
LOG_PATH = CFG.path("log")
|
||||
SCHEDULE_PATH = CFG.path("schedule")
|
||||
SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target
|
||||
MAX_CHARS = int(CFG.app["max_chars"])
|
||||
|
||||
|
|
@ -443,6 +445,55 @@ class Player:
|
|||
player = Player()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Planlagte sendinger
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
schedule = sched.Schedule(
|
||||
SCHEDULE_PATH,
|
||||
grace=float(CFG.get("schedule", "grace_seconds", sched.DEFAULT_GRACE)),
|
||||
max_ahead=float(CFG.get("schedule", "max_days_ahead", 30)) * 86400,
|
||||
)
|
||||
TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20)))
|
||||
|
||||
|
||||
def _scheduler() -> None:
|
||||
"""Flytter forfalte meldinger inn i den vanlige køen.
|
||||
|
||||
Egen tråd, og den gjør med vilje ingenting annet: avspilling, syntese og
|
||||
feilhåndtering er Players jobb, slik at en planlagt melding oppfører seg
|
||||
nøyaktig som en sendt for hånd når den først er i kø.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
send, missed = schedule.due()
|
||||
for item in missed:
|
||||
schedule.mark(item.id, "missed", "maskina var ikke i gang i tide")
|
||||
log.warning("planlagt #%s gikk glipp av (%s)", item.id, item.text[:40])
|
||||
audit("missed", id=item.id, subject=item.subject, name=item.user,
|
||||
method=item.method, text=item.text, at=item.at)
|
||||
for item in send:
|
||||
queued = player.enqueue(
|
||||
item.text, item.voice, item.speed, item.chime,
|
||||
sender="planlagt",
|
||||
who=auth.Principal(item.subject, item.user, (), item.method, frozenset()),
|
||||
)
|
||||
schedule.mark(item.id, "sent", f"kø #{queued.id}")
|
||||
log.info("planlagt #%s lagt i kø som #%d", item.id, queued.id)
|
||||
audit("scheduled_fire", id=item.id, queue_id=queued.id,
|
||||
subject=item.subject, name=item.user, method=item.method,
|
||||
text=item.text, voice=item.voice, at=item.at)
|
||||
except Exception: # noqa: BLE001
|
||||
# En feil her skal ikke drepe tråden; da ville alle senere
|
||||
# planlagte sendinger stilltiende blitt borte.
|
||||
log.exception("planleggeren feilet")
|
||||
time.sleep(TICK)
|
||||
|
||||
|
||||
threading.Thread(target=_scheduler, daemon=True, name="scheduler").start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -532,6 +583,10 @@ def client_config() -> Response:
|
|||
"chimes": list_chimes(),
|
||||
"default_chime": DEFAULT_CHIME,
|
||||
"max_chars": MAX_CHARS,
|
||||
"schedule": {
|
||||
"max_days_ahead": int(CFG.get("schedule", "max_days_ahead", 30)),
|
||||
"grace_seconds": int(CFG.get("schedule", "grace_seconds", 120)),
|
||||
},
|
||||
"auth": {
|
||||
**AUTH.describe(),
|
||||
"user": None if me.anonymous else me.public(),
|
||||
|
|
@ -568,6 +623,44 @@ def announce() -> Response:
|
|||
return jsonify(item.public(show_ip=me.can("clear")))
|
||||
|
||||
|
||||
@app.post("/api/schedule")
|
||||
def schedule_add() -> Response:
|
||||
me = require("schedule")
|
||||
text, voice, speed, chime = parse_request()
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
at = float(data["at"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
abort(400, "Mangler eller ugyldig tidspunkt")
|
||||
try:
|
||||
item = schedule.add(at=at, text=text, voice=voice, speed=speed, chime=chime,
|
||||
user=me.name, subject=me.subject, method=me.method)
|
||||
except ValueError as e:
|
||||
abort(400, str(e))
|
||||
log.info("planlagt #%s kl. %s av %s: %s", item.id,
|
||||
time.strftime("%Y-%m-%d %H:%M", time.localtime(at)), me.name or "anonym", text)
|
||||
audit("schedule", **who(me), id=item.id, at=at, text=text, voice=voice,
|
||||
speed=speed, chime=chime)
|
||||
return jsonify(item.public())
|
||||
|
||||
|
||||
@app.post("/api/schedule/cancel")
|
||||
def schedule_cancel() -> Response:
|
||||
me = require("schedule")
|
||||
data = request.get_json(silent=True) or {}
|
||||
sched_id = str(data.get("id", "")).strip()
|
||||
item = schedule.get(sched_id)
|
||||
if item is None or item.status != "pending":
|
||||
abort(404, "Fant ingen planlagt sending med den id-en")
|
||||
# Sin egen kan man alltid avbryte. Andres krever den samme retten som
|
||||
# å tømme køen – det er samme slags inngrep i noe andre har satt i gang.
|
||||
if item.subject != me.subject and not me.can("clear"):
|
||||
abort(403, f"{item.user or 'En annen'} planla denne. Du kan bare avbryte dine egne.")
|
||||
schedule.cancel(sched_id)
|
||||
audit("schedule_cancel", **who(me), id=sched_id, text=item.text)
|
||||
return jsonify({"ok": True, "id": sched_id})
|
||||
|
||||
|
||||
@app.post("/api/stop")
|
||||
def stop() -> Response:
|
||||
me = require("stop")
|
||||
|
|
@ -589,7 +682,11 @@ def status() -> Response:
|
|||
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem
|
||||
# som har sendt noe, og det er ikke noe alle utenfra skal få se.
|
||||
me = require("view")
|
||||
return jsonify(player.snapshot(show_ip=me.can("clear")))
|
||||
return jsonify({
|
||||
**player.snapshot(show_ip=me.can("clear")),
|
||||
"scheduled": schedule.snapshot(),
|
||||
"now": time.time(), # klienten regner «om 20 min» mot serverens klokke
|
||||
})
|
||||
|
||||
|
||||
@app.errorhandler(400)
|
||||
|
|
@ -636,8 +733,9 @@ def main() -> None:
|
|||
if CFG.source:
|
||||
log.info("konfigurasjon fra %s", CFG.source)
|
||||
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
|
||||
log.info("klar på http://%s:%d (auth: %s, sink: %s)",
|
||||
args.host, args.port, methods, SINK or "standard")
|
||||
venter = len(schedule.snapshot()["pending"])
|
||||
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
|
||||
args.host, args.port, methods, SINK or "standard", venter)
|
||||
app.run(host=args.host, port=args.port, threaded=True)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ services:
|
|||
ANNOUNCER_CACHE: /data/cache
|
||||
ANNOUNCER_LOG: /data/logs/announcements.jsonl
|
||||
ANNOUNCER_STATE: /data/instance
|
||||
ANNOUNCER_VOICES: /data/voices
|
||||
# Gjenbruker stemmene i repoet (bind-mountet på /app), så testriggen
|
||||
# ikke laster ned 670 MB til en gang.
|
||||
ANNOUNCER_VOICES: /app/voices
|
||||
ANNOUNCER_CHIMES: /data/chimes
|
||||
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
|
||||
ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker
|
||||
ANNOUNCER_PUBLIC_URL: http://localhost:8091
|
||||
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
|
||||
TZ: ${TZ:-Europe/Oslo}
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./data:/data
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ services:
|
|||
ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-}
|
||||
ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-}
|
||||
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
|
||||
# Planlagte sendinger lagres som absolutt tid, så TZ påvirker dem ikke.
|
||||
# Men uten den står loggen i UTC mens du står i CEST, og da blir det
|
||||
# vanskelig å se om noe gikk som det skulle.
|
||||
TZ: ${TZ:-Europe/Oslo}
|
||||
|
||||
volumes:
|
||||
# Stemmer, buffer, logg, sesjonsnøkkel, egne jingler og config.toml.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,16 @@ behind_tls = false
|
|||
# log = "logs/announcements.jsonl"
|
||||
# state = "instance"
|
||||
|
||||
[schedule]
|
||||
# Slingringsmonn etter planlagt tid. Tråden våkner med noen sekunders
|
||||
# mellomrom, og en kjapp omstart skal ikke miste en sending. Mer enn dette
|
||||
# for sent regnes som gått glipp av, og sendes *ikke* – et utrop om noe som
|
||||
# skjedde for en time siden er feil å spille.
|
||||
grace_seconds = 120
|
||||
# Fanger opp tastefeil i årstall.
|
||||
max_days_ahead = 30
|
||||
tick_seconds = 20
|
||||
|
||||
[audio]
|
||||
# pw-play --target / paplay --device. Tom = systemets standardutgang.
|
||||
# Finn navnet med: pactl list short sinks
|
||||
|
|
@ -112,6 +122,7 @@ label = "Logg inn med crew-kontoen"
|
|||
# view se køen og hva som spilles nå
|
||||
# preview forhåndslytte (synteser lyd)
|
||||
# announce sende ut på anlegget
|
||||
# schedule planlegge en sending fram i tid, og avbryte sine egne
|
||||
# stop stoppe det som spilles
|
||||
# clear tømme køen
|
||||
# "*" betyr alt.
|
||||
|
|
@ -119,5 +130,5 @@ label = "Logg inn med crew-kontoen"
|
|||
[roles]
|
||||
# Alle som ikke er innlogget. ["view"] er nyttig for en statusskjerm på veggen.
|
||||
anonymous = []
|
||||
crew = ["view", "preview", "announce"]
|
||||
crew = ["view", "preview", "announce", "schedule"]
|
||||
admin = ["*"]
|
||||
|
|
|
|||
16
config.py
16
config.py
|
|
@ -26,7 +26,7 @@ from typing import Any, Callable
|
|||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
# Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse.
|
||||
ACTIONS = ("view", "preview", "announce", "stop", "clear")
|
||||
ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear")
|
||||
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"app": {
|
||||
|
|
@ -46,6 +46,7 @@ DEFAULTS: dict[str, Any] = {
|
|||
"chimes": str(HERE / "chimes"),
|
||||
"log": str(HERE / "logs" / "announcements.jsonl"),
|
||||
"state": str(HERE / "instance"),
|
||||
"schedule": str(HERE / "data" / "scheduled.json"),
|
||||
},
|
||||
"audio": {
|
||||
# pw-play --target / paplay --device. Tom = systemets standardutgang.
|
||||
|
|
@ -61,6 +62,16 @@ DEFAULTS: dict[str, Any] = {
|
|||
# Full URL utenfra, trengs for OIDC redirect_uri bak en proxy.
|
||||
"public_url": "",
|
||||
},
|
||||
"schedule": {
|
||||
# Slingringsmonn etter planlagt tid. Tråden våkner med noen sekunders
|
||||
# mellomrom, og en kjapp omstart skal ikke miste en sending. Mer enn
|
||||
# dette for sent regnes som gått glipp av, og sendes ikke – et utrop
|
||||
# om noe som skjedde for en time siden er feil å spille.
|
||||
"grace_seconds": 120,
|
||||
"max_days_ahead": 30,
|
||||
# Hvor ofte vi ser etter forfalte sendinger.
|
||||
"tick_seconds": 20,
|
||||
},
|
||||
"auth": {
|
||||
# Tom liste = alt åpent. Se active_providers(): en leverandør er også
|
||||
# aktiv når den er ferdig konfigurert, så det holder å sette
|
||||
|
|
@ -105,7 +116,7 @@ DEFAULTS: dict[str, Any] = {
|
|||
},
|
||||
"roles": {
|
||||
"anonymous": [],
|
||||
"crew": ["view", "preview", "announce"],
|
||||
"crew": ["view", "preview", "announce", "schedule"],
|
||||
"admin": ["*"],
|
||||
},
|
||||
}
|
||||
|
|
@ -131,6 +142,7 @@ ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = {
|
|||
"ANNOUNCER_CACHE": (("paths", "cache"), str),
|
||||
"ANNOUNCER_CHIMES": (("paths", "chimes"), str),
|
||||
"ANNOUNCER_LOG": (("paths", "log"), str),
|
||||
"ANNOUNCER_SCHEDULE": (("paths", "schedule"), str),
|
||||
"ANNOUNCER_STATE": (("paths", "state"), str),
|
||||
"ANNOUNCER_SINK": (("audio", "sink"), str),
|
||||
"ANNOUNCER_HOST": (("server", "host"), str),
|
||||
|
|
|
|||
|
|
@ -35,5 +35,5 @@ token_endpoint = "http://idp:9000/oauth/token"
|
|||
|
||||
[roles]
|
||||
anonymous = ["view"]
|
||||
crew = ["view", "preview", "announce"]
|
||||
crew = ["view", "preview", "announce", "schedule"]
|
||||
admin = ["*"]
|
||||
|
|
|
|||
|
|
@ -167,13 +167,14 @@ Fem handlinger. `config.toml` kobler rollenavn til dem:
|
|||
| `view` | se køen og hva som spilles nå |
|
||||
| `preview` | forhåndslytte (koster en syntese) |
|
||||
| `announce` | sende ut på anlegget |
|
||||
| `schedule` | planlegge en sending fram i tid, og avbryte sine egne |
|
||||
| `stop` | stoppe det som spilles |
|
||||
| `clear` | tømme køen (og se IP-adresser i køen) |
|
||||
|
||||
```toml
|
||||
[roles]
|
||||
anonymous = [] # ["view"] for en statusskjerm
|
||||
crew = ["view", "preview", "announce"]
|
||||
crew = ["view", "preview", "announce", "schedule"]
|
||||
admin = ["*"]
|
||||
```
|
||||
|
||||
|
|
|
|||
125
index.html
125
index.html
|
|
@ -64,6 +64,18 @@
|
|||
#pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
.hidden { display: none !important; }
|
||||
.when { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
|
||||
.when input[type=datetime-local] { background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 10px; font: inherit; }
|
||||
.when button { flex: 0 1 auto; padding: 10px 14px; font-size: 14px; }
|
||||
.when .q { display: flex; gap: 6px; }
|
||||
.when .q button { background: var(--panel2); border: 1px solid var(--line); color: var(--fg);
|
||||
border-radius: 999px; padding: 6px 10px; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.when .q button:hover { border-color: var(--accent); }
|
||||
ul.list li button.x { background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
font: inherit; font-size: 13px; padding: 0 4px; }
|
||||
ul.list li button.x:hover { color: var(--danger); }
|
||||
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -112,10 +124,20 @@
|
|||
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
|
||||
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
|
||||
</div>
|
||||
<div class="when" id="whenbox">
|
||||
<input id="at" type="datetime-local" step="60">
|
||||
<div class="q" id="atquick"></div>
|
||||
<button class="btn" id="btnSchedule" disabled>🕑 Planlegg</button>
|
||||
</div>
|
||||
<audio id="player" controls></audio>
|
||||
<div class="msg" id="msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="card hidden" id="schedcard">
|
||||
<div class="status-head"><h2>Planlagt</h2></div>
|
||||
<ul class="list" id="sched"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="status-head">
|
||||
<h2>Anlegget</h2>
|
||||
|
|
@ -138,7 +160,7 @@
|
|||
const $ = s => document.querySelector(s);
|
||||
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
|
||||
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
|
||||
let cfg = {}, can = [], previewedKey = null, pollTimer = null;
|
||||
let cfg = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0;
|
||||
|
||||
function key() { return JSON.stringify(payload()); }
|
||||
function payload() {
|
||||
|
|
@ -161,6 +183,44 @@ function updateAnnounce() {
|
|||
(btnAnnounce.disabled ? 'Forhåndslytt først' : '');
|
||||
btnPreview.disabled = !may('preview');
|
||||
btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte';
|
||||
|
||||
// Planlegging er låst til forhåndslytten på samme måte som «Send ut»:
|
||||
// ingen skal legge noe i kalenderen de ikke har hørt.
|
||||
const at = $('#at').value;
|
||||
$('#whenbox').classList.toggle('hidden', !may('schedule'));
|
||||
$('#btnSchedule').disabled = !ready || !at || !may('schedule');
|
||||
$('#btnSchedule').title = !at ? 'Velg tidspunkt' :
|
||||
(!ready ? 'Forhåndslytt først' : '');
|
||||
}
|
||||
|
||||
// Serverens klokke gjelder. Containeren kan stå i UTC mens nettleseren står
|
||||
// i CEST, og da ville «om 20 minutter» blitt to timer feil.
|
||||
function serverNow() { return Date.now() / 1000 + skew; }
|
||||
|
||||
function atEpoch() {
|
||||
const v = $('#at').value;
|
||||
if (!v) return null;
|
||||
const t = new Date(v).getTime(); // datetime-local tolkes lokalt
|
||||
return Number.isFinite(t) ? t / 1000 : null;
|
||||
}
|
||||
|
||||
function omTid(ts) {
|
||||
const s = ts - serverNow();
|
||||
if (s < 0) return 'nå';
|
||||
if (s < 90) return 'om ' + Math.round(s) + ' s';
|
||||
if (s < 5400) return 'om ' + Math.round(s / 60) + ' min';
|
||||
if (s < 86400) return 'om ' + (s / 3600).toFixed(1).replace('.0', '') + ' t';
|
||||
return 'om ' + Math.round(s / 86400) + ' d';
|
||||
}
|
||||
|
||||
function setAtIn(minutes) {
|
||||
const d = new Date(Date.now() + minutes * 60000);
|
||||
d.setSeconds(0, 0);
|
||||
// datetime-local vil ha lokal tid uten sone, ikke ISO i UTC.
|
||||
const pad = v => String(v).padStart(2, '0');
|
||||
$('#at').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
updateAnnounce();
|
||||
}
|
||||
|
||||
async function req(path, opts) {
|
||||
|
|
@ -250,6 +310,7 @@ function applyAuth(auth) {
|
|||
if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || '';
|
||||
}
|
||||
|
||||
me = auth.user;
|
||||
$('#btnStop').classList.toggle('hidden', !may('stop'));
|
||||
$('#btnClear').classList.toggle('hidden', !may('clear'));
|
||||
updateAnnounce();
|
||||
|
|
@ -281,10 +342,42 @@ async function init() {
|
|||
if (!chime.value) chime.value = c.default_chime;
|
||||
|
||||
applyAuth(c.auth || {});
|
||||
$('#atquick').replaceChildren(...[
|
||||
['+5 min', 5], ['+15 min', 15], ['+1 t', 60], ['+3 t', 180],
|
||||
].map(([label, min]) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = label; b.onclick = () => setAtIn(min);
|
||||
return b;
|
||||
}));
|
||||
poll();
|
||||
pollTimer = setInterval(poll, 2000);
|
||||
}
|
||||
|
||||
$('#at').addEventListener('input', updateAnnounce);
|
||||
|
||||
$('#btnSchedule').addEventListener('click', async () => {
|
||||
const at = atEpoch();
|
||||
if (!at) return say('Velg et tidspunkt først.', 'err');
|
||||
if (at < serverNow()) return say('Tidspunktet er passert.', 'err');
|
||||
$('#btnSchedule').disabled = true;
|
||||
try {
|
||||
const item = await (await api('/api/schedule', { ...payload(), at })).json();
|
||||
say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', {
|
||||
weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok');
|
||||
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
|
||||
$('#at').value = '';
|
||||
audio.pause(); audio.classList.remove('show');
|
||||
poll();
|
||||
} catch (e) { onError(e); }
|
||||
finally { updateAnnounce(); }
|
||||
});
|
||||
|
||||
async function cancelSched(id, what) {
|
||||
if (!confirm(`Avbryte den planlagte meldingen?\n\n${what}`)) return;
|
||||
try { await api('/api/schedule/cancel', { id }); say('Avbrutt.', 'ok'); poll(); }
|
||||
catch (e) { onError(e); }
|
||||
}
|
||||
|
||||
let pinDebounce = null;
|
||||
$('#pin').addEventListener('input', e => {
|
||||
localStorage.setItem('pin', e.target.value);
|
||||
|
|
@ -341,10 +434,40 @@ function li(it) {
|
|||
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
|
||||
return l;
|
||||
}
|
||||
const SCHED_STATUS = { sent: 'sendt', missed: 'gikk glipp av', cancelled: 'avbrutt' };
|
||||
function schedLi(it) {
|
||||
const l = document.createElement('li');
|
||||
l.className = it.status;
|
||||
l.innerHTML = '<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>';
|
||||
const when = new Date(it.at * 1000);
|
||||
l.querySelector('.t').textContent = when.toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' });
|
||||
l.querySelector('.x').textContent = it.text;
|
||||
l.querySelector('.by').textContent = it.user || '';
|
||||
const s = l.querySelector('.s');
|
||||
if (it.status === 'pending') {
|
||||
s.textContent = omTid(it.at) + ' ';
|
||||
// Egne kan man alltid avbryte; andres krever samme rett som å tømme kø.
|
||||
if (may('schedule') && (!me || !it.user || may('clear') || it.user === me.name)) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'x'; b.textContent = '✕'; b.title = 'Avbryt';
|
||||
b.onclick = () => cancelSched(it.id, it.text);
|
||||
s.append(b);
|
||||
}
|
||||
} else {
|
||||
s.textContent = (SCHED_STATUS[it.status] || it.status) + (it.note ? ' · ' + it.note : '');
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const now = $('#now');
|
||||
try {
|
||||
const s = await (await req('/api/status', { headers: headers(false) })).json();
|
||||
if (s.now) skew = s.now - Date.now() / 1000;
|
||||
const sc = s.scheduled || { pending: [], done: [] };
|
||||
const rows = [...sc.pending, ...sc.done];
|
||||
$('#schedcard').classList.toggle('hidden', rows.length === 0);
|
||||
$('#sched').replaceChildren(...rows.map(schedLi));
|
||||
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
|
||||
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
|
||||
$('#queue').replaceChildren(...s.queued.map(li));
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ PUID=$(id -u)
|
|||
PGID=$(id -g)
|
||||
XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
|
||||
BIND_IP=$BIND_IP
|
||||
TZ=$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo Europe/Oslo)
|
||||
ANNOUNCER_PORT=${ANNOUNCER_PORT:-8090}
|
||||
ANNOUNCER_SINK=${ANNOUNCER_SINK:-}
|
||||
ANNOUNCER_TITLE=${ANNOUNCER_TITLE:-}
|
||||
|
|
|
|||
190
schedule.py
Normal file
190
schedule.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/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
|
||||
|
||||
STATUSES = ("pending", "sent", "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" # pending | sent | missed | cancelled
|
||||
note: str = ""
|
||||
|
||||
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") -> 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,
|
||||
)
|
||||
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],
|
||||
}
|
||||
|
|
@ -358,12 +358,13 @@ def test_nestet_claim_hentes():
|
|||
def test_rolle_til_handling():
|
||||
cfg = config.load("/finnes-ikke.toml")
|
||||
assert cfg.permissions(("admin",)) == frozenset(config.ACTIONS)
|
||||
assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce"})
|
||||
assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce", "schedule"})
|
||||
assert cfg.permissions(("ukjent-rolle",)) == frozenset()
|
||||
assert cfg.permissions(()) == frozenset()
|
||||
# Roller legges sammen
|
||||
cfg.data["roles"]["bare_stopp"] = ["stop"]
|
||||
assert cfg.permissions(("crew", "bare_stopp")) == frozenset({"view", "preview", "announce", "stop"})
|
||||
assert cfg.permissions(("crew", "bare_stopp")) == frozenset(
|
||||
{"view", "preview", "announce", "schedule", "stop"})
|
||||
|
||||
|
||||
def test_ukjente_handlinger_i_roles_ignoreres():
|
||||
|
|
|
|||
235
tests/test_schedule.py
Normal file
235
tests/test_schedule.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Tester for planlagte sendinger.
|
||||
|
||||
Det som er verdt å prøve her er ikke at en melding kan legges i en liste, men
|
||||
de to tingene som faktisk kan svikte: at lista overlever en omstart, og at en
|
||||
melding som skulle gått for en time siden *ikke* spilles når maskina kommer
|
||||
tilbake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import schedule as sched # noqa: E402
|
||||
|
||||
|
||||
def mk(tmp_path: Path, **kw) -> sched.Schedule:
|
||||
return sched.Schedule(tmp_path / "scheduled.json", **kw)
|
||||
|
||||
|
||||
def add(s: sched.Schedule, *, om: float = 3600, text: str = "melding", **kw):
|
||||
"""Legger til en melding «om» så mange sekunder."""
|
||||
return s.add(at=time.time() + om, text=text, voice="finn", speed=1.0,
|
||||
chime="none", **kw)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lagring – grunnen til at modulen finnes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_planen_overlever_omstart(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
item = add(s, om=1800, text="Pizzaen kommer", user="Kari", subject="jwt:7")
|
||||
# Ny instans, som etter en restart av containeren
|
||||
s2 = mk(tmp_path)
|
||||
assert len(s2.items) == 1
|
||||
got = s2.items[0]
|
||||
assert (got.id, got.text, got.user, got.subject) == (item.id, "Pizzaen kommer", "Kari", "jwt:7")
|
||||
assert got.status == "pending"
|
||||
|
||||
|
||||
def test_fila_er_lesbar_json(tmp_path):
|
||||
"""Poenget med fil framfor database: man skal kunne cat'e den."""
|
||||
s = mk(tmp_path)
|
||||
add(s, text="Hallo")
|
||||
raw = json.loads((tmp_path / "scheduled.json").read_text())
|
||||
assert raw["items"][0]["text"] == "Hallo"
|
||||
|
||||
|
||||
def test_ingen_tmp_fil_ligger_igjen(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
add(s)
|
||||
assert not list(tmp_path.glob("*.tmp")), "atomisk skriving skal rydde etter seg"
|
||||
|
||||
|
||||
def test_odelagt_fil_hindrer_ikke_oppstart(tmp_path):
|
||||
(tmp_path / "scheduled.json").write_text("{dette er ikke json")
|
||||
s = mk(tmp_path) # skal ikke kaste
|
||||
assert s.items == []
|
||||
add(s, text="virker likevel")
|
||||
assert len(mk(tmp_path).items) == 1
|
||||
|
||||
|
||||
def test_ukjente_felt_i_fila_ignoreres(tmp_path):
|
||||
"""Så en nyere versjon av fila ikke velter en eldre app."""
|
||||
(tmp_path / "scheduled.json").write_text(json.dumps({"items": [{
|
||||
"id": "abc", "at": time.time() + 60, "text": "x", "voice": "finn",
|
||||
"speed": 1.0, "chime": "none", "noe_nytt": 42,
|
||||
}]}))
|
||||
s = mk(tmp_path)
|
||||
assert len(s.items) == 1 and s.items[0].id == "abc"
|
||||
|
||||
|
||||
def test_tom_mappe_er_greit(tmp_path):
|
||||
s = mk(tmp_path / "finnes" / "ikke" / "enda")
|
||||
add(s)
|
||||
assert len(s.items) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Validering
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tidspunkt_i_fortiden_avvises(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
with pytest.raises(ValueError, match="passert"):
|
||||
add(s, om=-3600)
|
||||
|
||||
|
||||
def test_tidspunkt_rett_bak_oss_godtas(tmp_path):
|
||||
"""Innenfor nådefristen; ellers ville «send nå» vært en kappløpssituasjon."""
|
||||
s = mk(tmp_path, grace=120)
|
||||
assert add(s, om=-30) is not None
|
||||
|
||||
|
||||
def test_for_langt_fram_avvises(tmp_path):
|
||||
s = mk(tmp_path, max_ahead=30 * 86400)
|
||||
with pytest.raises(ValueError, match="dager"):
|
||||
add(s, om=400 * 86400) # typisk tastefeil i årstall
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# due(): skal den sendes, eller gikk den glipp av?
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ikke_forfalt_blir_liggende(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
add(s, om=600)
|
||||
send, missed = s.due()
|
||||
assert (send, missed) == ([], [])
|
||||
|
||||
|
||||
def test_forfalt_innenfor_nadefrist_sendes(tmp_path):
|
||||
s = mk(tmp_path, grace=120)
|
||||
item = add(s, om=600)
|
||||
# Som om klokka har gått fram til litt etter planlagt tid.
|
||||
send, missed = s.due(now=item.at + 30)
|
||||
assert [i.id for i in send] == [item.id]
|
||||
assert missed == []
|
||||
|
||||
|
||||
def test_for_sent_gar_glipp_av_og_spilles_ikke(tmp_path):
|
||||
"""Kjernen i det Kent valgte: hopp over, men vis det.
|
||||
|
||||
Forløpet er: meldingen ble lagt inn i god tid, maskina var av da den
|
||||
skulle gått, og nå er den tilbake en time for sent. «Turneringen starter
|
||||
om ti minutter» skal ikke ut på anlegget da."""
|
||||
s = mk(tmp_path, grace=120)
|
||||
item = add(s, om=600)
|
||||
send, missed = s.due(now=item.at + 3600)
|
||||
assert send == []
|
||||
assert [i.id for i in missed] == [item.id]
|
||||
|
||||
|
||||
def test_due_endrer_ingenting_selv(tmp_path):
|
||||
"""announcer.py markerer først etter at meldingen faktisk er i kø, så en
|
||||
melding ikke kan stå som «sendt» uten å ha blitt det."""
|
||||
s = mk(tmp_path)
|
||||
item = add(s, om=600)
|
||||
s.due(now=item.at + 10)
|
||||
assert mk(tmp_path).items[0].status == "pending"
|
||||
|
||||
|
||||
def test_avbrutt_forfaller_ikke(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
item = add(s, om=600)
|
||||
s.cancel(item.id)
|
||||
assert s.due(now=item.at + 10) == ([], [])
|
||||
|
||||
|
||||
def test_sendt_forfaller_ikke_igjen(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
item = add(s, om=600)
|
||||
s.mark(item.id, "sent", "kø #4")
|
||||
assert s.due(now=item.at + 10) == ([], [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Avbryting og markering
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_avbryting_lagres(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
item = add(s)
|
||||
assert s.cancel(item.id) is not None
|
||||
assert mk(tmp_path).items[0].status == "cancelled"
|
||||
|
||||
|
||||
def test_avbryting_to_ganger_gir_none(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
item = add(s)
|
||||
s.cancel(item.id)
|
||||
assert s.cancel(item.id) is None
|
||||
|
||||
|
||||
def test_avbryting_av_ukjent_id_gir_none(tmp_path):
|
||||
assert mk(tmp_path).cancel("finnes-ikke") is None
|
||||
|
||||
|
||||
def test_mark_lagres(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
item = add(s)
|
||||
s.mark(item.id, "missed", "maskina var av")
|
||||
got = mk(tmp_path).items[0]
|
||||
assert (got.status, got.note) == ("missed", "maskina var av")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Visning
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_snapshot_sorterer_ventende_kronologisk(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
add(s, om=7200, text="sist")
|
||||
add(s, om=600, text="først")
|
||||
add(s, om=3600, text="midt")
|
||||
assert [i["text"] for i in s.snapshot()["pending"]] == ["først", "midt", "sist"]
|
||||
|
||||
|
||||
def test_snapshot_skiller_ventende_fra_ferdige(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
a = add(s, om=600, text="venter")
|
||||
add(s, om=1200, text="venter også")
|
||||
s.mark(a.id, "sent")
|
||||
snap = s.snapshot()
|
||||
assert [i["text"] for i in snap["pending"]] == ["venter også"]
|
||||
assert [i["text"] for i in snap["done"]] == ["venter"]
|
||||
|
||||
|
||||
def test_subject_lekker_ikke_ut_til_klienten(tmp_path):
|
||||
"""Navnet skal vises, bruker-id-en er intern."""
|
||||
s = mk(tmp_path)
|
||||
add(s, user="Kari", subject="jwt:7")
|
||||
pub = s.snapshot()["pending"][0]
|
||||
assert pub["user"] == "Kari"
|
||||
assert "subject" not in pub
|
||||
|
||||
|
||||
def test_ferdige_hoper_seg_ikke_opp(tmp_path):
|
||||
s = mk(tmp_path)
|
||||
for i in range(sched.KEEP_DONE + 20):
|
||||
item = add(s, om=600 + i, text=f"nr {i}")
|
||||
s.mark(item.id, "sent")
|
||||
assert len([i for i in s.items if i.status != "pending"]) == sched.KEEP_DONE
|
||||
Loading…
Add table
Add a link
Reference in a new issue