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
|
BIND_IP=0.0.0.0
|
||||||
ANNOUNCER_PORT=8090
|
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
|
# Lydutgang. Tom = systemets standard. Finn navn med: pactl list short sinks
|
||||||
ANNOUNCER_SINK=
|
ANNOUNCER_SINK=
|
||||||
|
|
||||||
|
|
|
||||||
49
AGENTS.md
49
AGENTS.md
|
|
@ -18,10 +18,11 @@ Filer:
|
||||||
| `announcer.py` | server, kø, TTS, jingler, ruter |
|
| `announcer.py` | server, kø, TTS, jingler, ruter |
|
||||||
| `auth.py` | `Principal`, leverandørene, OIDC-rutene |
|
| `auth.py` | `Principal`, leverandørene, OIDC-rutene |
|
||||||
| `config.py` | `config.toml` + miljøvariabler, standardverdier |
|
| `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 |
|
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
|
||||||
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
||||||
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
|
| `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 |
|
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
|
||||||
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
|
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
|
||||||
| `compose.test.yaml` | testoppsett med dummy-IdP, se under |
|
| `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/chimes/` | egne jinglefiler |
|
||||||
| `data/logs/` | revisjonsloggen |
|
| `data/logs/` | revisjonsloggen |
|
||||||
| `data/instance/` | sesjonsnøkkelen |
|
| `data/instance/` | sesjonsnøkkelen |
|
||||||
|
| `data/scheduled.json` | planlagte sendinger. Lesbar med `cat` |
|
||||||
| `data/config.toml` | valgfri. Uten den er sida åpen |
|
| `data/config.toml` | valgfri. Uten den er sida åpen |
|
||||||
|
|
||||||
### Lyden ut av containeren
|
### 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
|
det er ryddet bort. Skal noe prøves med innlogging, bruk
|
||||||
`compose.test.yaml`.
|
`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
|
## Hvordan det henger sammen
|
||||||
|
|
||||||
- `/api/config` er alt sida trenger for å tegne seg selv: branding, stemmer,
|
- `/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
|
- `player = Player()` står på modulnivå, så avspillingstråden starter ved
|
||||||
`import announcer` – også i `install.sh`, som importerer modulen for å laste
|
`import announcer` – også i `install.sh`, som importerer modulen for å laste
|
||||||
ned stemmer. Harmløst, men greit å vite.
|
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.
|
- `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test.
|
||||||
Uten den arver testene maskinens miljø – systemd-enheten setter
|
Uten den arver testene maskinens miljø – systemd-enheten setter
|
||||||
`ANNOUNCER_PORT`, og compose setter JWT- og OIDC-hemmeligheter, noe som
|
`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.
|
# velger selv hvilken som finnes (player_cmd()), så begge lydservere virker.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
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
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
# Koden sist, så avhengighetslaget gjenbrukes når bare appen endres.
|
# 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
|
# 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.
|
# 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_CHIMES=/data/chimes \
|
||||||
ANNOUNCER_LOG=/data/logs/announcements.jsonl \
|
ANNOUNCER_LOG=/data/logs/announcements.jsonl \
|
||||||
ANNOUNCER_STATE=/data/instance \
|
ANNOUNCER_STATE=/data/instance \
|
||||||
|
ANNOUNCER_SCHEDULE=/data/scheduled.json \
|
||||||
ANNOUNCER_CONFIG=/data/config.toml \
|
ANNOUNCER_CONFIG=/data/config.toml \
|
||||||
ANNOUNCER_PORT=8090
|
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 auth
|
||||||
import config
|
import config
|
||||||
|
import schedule as sched
|
||||||
from auth import require
|
from auth import require
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
@ -38,6 +39,7 @@ CFG = config.load()
|
||||||
VOICE_DIR = CFG.path("voices")
|
VOICE_DIR = CFG.path("voices")
|
||||||
CACHE_DIR = CFG.path("cache")
|
CACHE_DIR = CFG.path("cache")
|
||||||
LOG_PATH = CFG.path("log")
|
LOG_PATH = CFG.path("log")
|
||||||
|
SCHEDULE_PATH = CFG.path("schedule")
|
||||||
SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target
|
SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target
|
||||||
MAX_CHARS = int(CFG.app["max_chars"])
|
MAX_CHARS = int(CFG.app["max_chars"])
|
||||||
|
|
||||||
|
|
@ -443,6 +445,55 @@ class Player:
|
||||||
player = 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
|
# HTTP
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -532,6 +583,10 @@ def client_config() -> Response:
|
||||||
"chimes": list_chimes(),
|
"chimes": list_chimes(),
|
||||||
"default_chime": DEFAULT_CHIME,
|
"default_chime": DEFAULT_CHIME,
|
||||||
"max_chars": MAX_CHARS,
|
"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": {
|
||||||
**AUTH.describe(),
|
**AUTH.describe(),
|
||||||
"user": None if me.anonymous else me.public(),
|
"user": None if me.anonymous else me.public(),
|
||||||
|
|
@ -568,6 +623,44 @@ def announce() -> Response:
|
||||||
return jsonify(item.public(show_ip=me.can("clear")))
|
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")
|
@app.post("/api/stop")
|
||||||
def stop() -> Response:
|
def stop() -> Response:
|
||||||
me = require("stop")
|
me = require("stop")
|
||||||
|
|
@ -589,7 +682,11 @@ def status() -> Response:
|
||||||
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem
|
# 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.
|
# som har sendt noe, og det er ikke noe alle utenfra skal få se.
|
||||||
me = require("view")
|
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)
|
@app.errorhandler(400)
|
||||||
|
|
@ -636,8 +733,9 @@ def main() -> None:
|
||||||
if CFG.source:
|
if CFG.source:
|
||||||
log.info("konfigurasjon fra %s", CFG.source)
|
log.info("konfigurasjon fra %s", CFG.source)
|
||||||
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
|
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)",
|
venter = len(schedule.snapshot()["pending"])
|
||||||
args.host, args.port, methods, SINK or "standard")
|
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)
|
app.run(host=args.host, port=args.port, threaded=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,15 @@ services:
|
||||||
ANNOUNCER_CACHE: /data/cache
|
ANNOUNCER_CACHE: /data/cache
|
||||||
ANNOUNCER_LOG: /data/logs/announcements.jsonl
|
ANNOUNCER_LOG: /data/logs/announcements.jsonl
|
||||||
ANNOUNCER_STATE: /data/instance
|
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_CHIMES: /data/chimes
|
||||||
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
|
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
|
||||||
ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker
|
ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker
|
||||||
ANNOUNCER_PUBLIC_URL: http://localhost:8091
|
ANNOUNCER_PUBLIC_URL: http://localhost:8091
|
||||||
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
|
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
|
||||||
|
TZ: ${TZ:-Europe/Oslo}
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ services:
|
||||||
ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-}
|
ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-}
|
||||||
ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-}
|
ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-}
|
||||||
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
|
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:
|
volumes:
|
||||||
# Stemmer, buffer, logg, sesjonsnøkkel, egne jingler og config.toml.
|
# Stemmer, buffer, logg, sesjonsnøkkel, egne jingler og config.toml.
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@ behind_tls = false
|
||||||
# log = "logs/announcements.jsonl"
|
# log = "logs/announcements.jsonl"
|
||||||
# state = "instance"
|
# 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]
|
[audio]
|
||||||
# pw-play --target / paplay --device. Tom = systemets standardutgang.
|
# pw-play --target / paplay --device. Tom = systemets standardutgang.
|
||||||
# Finn navnet med: pactl list short sinks
|
# 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å
|
# view se køen og hva som spilles nå
|
||||||
# preview forhåndslytte (synteser lyd)
|
# preview forhåndslytte (synteser lyd)
|
||||||
# announce sende ut på anlegget
|
# announce sende ut på anlegget
|
||||||
|
# schedule planlegge en sending fram i tid, og avbryte sine egne
|
||||||
# stop stoppe det som spilles
|
# stop stoppe det som spilles
|
||||||
# clear tømme køen
|
# clear tømme køen
|
||||||
# "*" betyr alt.
|
# "*" betyr alt.
|
||||||
|
|
@ -119,5 +130,5 @@ label = "Logg inn med crew-kontoen"
|
||||||
[roles]
|
[roles]
|
||||||
# Alle som ikke er innlogget. ["view"] er nyttig for en statusskjerm på veggen.
|
# Alle som ikke er innlogget. ["view"] er nyttig for en statusskjerm på veggen.
|
||||||
anonymous = []
|
anonymous = []
|
||||||
crew = ["view", "preview", "announce"]
|
crew = ["view", "preview", "announce", "schedule"]
|
||||||
admin = ["*"]
|
admin = ["*"]
|
||||||
|
|
|
||||||
16
config.py
16
config.py
|
|
@ -26,7 +26,7 @@ from typing import Any, Callable
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
# Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse.
|
# 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] = {
|
DEFAULTS: dict[str, Any] = {
|
||||||
"app": {
|
"app": {
|
||||||
|
|
@ -46,6 +46,7 @@ DEFAULTS: dict[str, Any] = {
|
||||||
"chimes": str(HERE / "chimes"),
|
"chimes": str(HERE / "chimes"),
|
||||||
"log": str(HERE / "logs" / "announcements.jsonl"),
|
"log": str(HERE / "logs" / "announcements.jsonl"),
|
||||||
"state": str(HERE / "instance"),
|
"state": str(HERE / "instance"),
|
||||||
|
"schedule": str(HERE / "data" / "scheduled.json"),
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
# pw-play --target / paplay --device. Tom = systemets standardutgang.
|
# 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.
|
# Full URL utenfra, trengs for OIDC redirect_uri bak en proxy.
|
||||||
"public_url": "",
|
"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": {
|
"auth": {
|
||||||
# Tom liste = alt åpent. Se active_providers(): en leverandør er også
|
# Tom liste = alt åpent. Se active_providers(): en leverandør er også
|
||||||
# aktiv når den er ferdig konfigurert, så det holder å sette
|
# aktiv når den er ferdig konfigurert, så det holder å sette
|
||||||
|
|
@ -105,7 +116,7 @@ DEFAULTS: dict[str, Any] = {
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"anonymous": [],
|
"anonymous": [],
|
||||||
"crew": ["view", "preview", "announce"],
|
"crew": ["view", "preview", "announce", "schedule"],
|
||||||
"admin": ["*"],
|
"admin": ["*"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -131,6 +142,7 @@ ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = {
|
||||||
"ANNOUNCER_CACHE": (("paths", "cache"), str),
|
"ANNOUNCER_CACHE": (("paths", "cache"), str),
|
||||||
"ANNOUNCER_CHIMES": (("paths", "chimes"), str),
|
"ANNOUNCER_CHIMES": (("paths", "chimes"), str),
|
||||||
"ANNOUNCER_LOG": (("paths", "log"), str),
|
"ANNOUNCER_LOG": (("paths", "log"), str),
|
||||||
|
"ANNOUNCER_SCHEDULE": (("paths", "schedule"), str),
|
||||||
"ANNOUNCER_STATE": (("paths", "state"), str),
|
"ANNOUNCER_STATE": (("paths", "state"), str),
|
||||||
"ANNOUNCER_SINK": (("audio", "sink"), str),
|
"ANNOUNCER_SINK": (("audio", "sink"), str),
|
||||||
"ANNOUNCER_HOST": (("server", "host"), str),
|
"ANNOUNCER_HOST": (("server", "host"), str),
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,5 @@ token_endpoint = "http://idp:9000/oauth/token"
|
||||||
|
|
||||||
[roles]
|
[roles]
|
||||||
anonymous = ["view"]
|
anonymous = ["view"]
|
||||||
crew = ["view", "preview", "announce"]
|
crew = ["view", "preview", "announce", "schedule"]
|
||||||
admin = ["*"]
|
admin = ["*"]
|
||||||
|
|
|
||||||
|
|
@ -167,13 +167,14 @@ Fem handlinger. `config.toml` kobler rollenavn til dem:
|
||||||
| `view` | se køen og hva som spilles nå |
|
| `view` | se køen og hva som spilles nå |
|
||||||
| `preview` | forhåndslytte (koster en syntese) |
|
| `preview` | forhåndslytte (koster en syntese) |
|
||||||
| `announce` | sende ut på anlegget |
|
| `announce` | sende ut på anlegget |
|
||||||
|
| `schedule` | planlegge en sending fram i tid, og avbryte sine egne |
|
||||||
| `stop` | stoppe det som spilles |
|
| `stop` | stoppe det som spilles |
|
||||||
| `clear` | tømme køen (og se IP-adresser i køen) |
|
| `clear` | tømme køen (og se IP-adresser i køen) |
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[roles]
|
[roles]
|
||||||
anonymous = [] # ["view"] for en statusskjerm
|
anonymous = [] # ["view"] for en statusskjerm
|
||||||
crew = ["view", "preview", "announce"]
|
crew = ["view", "preview", "announce", "schedule"]
|
||||||
admin = ["*"]
|
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%; }
|
#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; }
|
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||||
.hidden { display: none !important; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -112,10 +124,20 @@
|
||||||
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
|
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
|
||||||
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
|
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
|
||||||
</div>
|
</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>
|
<audio id="player" controls></audio>
|
||||||
<div class="msg" id="msg"></div>
|
<div class="msg" id="msg"></div>
|
||||||
</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="card">
|
||||||
<div class="status-head">
|
<div class="status-head">
|
||||||
<h2>Anlegget</h2>
|
<h2>Anlegget</h2>
|
||||||
|
|
@ -138,7 +160,7 @@
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
|
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
|
||||||
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
|
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 key() { return JSON.stringify(payload()); }
|
||||||
function payload() {
|
function payload() {
|
||||||
|
|
@ -161,6 +183,44 @@ function updateAnnounce() {
|
||||||
(btnAnnounce.disabled ? 'Forhåndslytt først' : '');
|
(btnAnnounce.disabled ? 'Forhåndslytt først' : '');
|
||||||
btnPreview.disabled = !may('preview');
|
btnPreview.disabled = !may('preview');
|
||||||
btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte';
|
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) {
|
async function req(path, opts) {
|
||||||
|
|
@ -250,6 +310,7 @@ function applyAuth(auth) {
|
||||||
if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || '';
|
if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
me = auth.user;
|
||||||
$('#btnStop').classList.toggle('hidden', !may('stop'));
|
$('#btnStop').classList.toggle('hidden', !may('stop'));
|
||||||
$('#btnClear').classList.toggle('hidden', !may('clear'));
|
$('#btnClear').classList.toggle('hidden', !may('clear'));
|
||||||
updateAnnounce();
|
updateAnnounce();
|
||||||
|
|
@ -281,10 +342,42 @@ async function init() {
|
||||||
if (!chime.value) chime.value = c.default_chime;
|
if (!chime.value) chime.value = c.default_chime;
|
||||||
|
|
||||||
applyAuth(c.auth || {});
|
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();
|
poll();
|
||||||
pollTimer = setInterval(poll, 2000);
|
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;
|
let pinDebounce = null;
|
||||||
$('#pin').addEventListener('input', e => {
|
$('#pin').addEventListener('input', e => {
|
||||||
localStorage.setItem('pin', e.target.value);
|
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 : '');
|
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
|
||||||
return l;
|
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() {
|
async function poll() {
|
||||||
const now = $('#now');
|
const now = $('#now');
|
||||||
try {
|
try {
|
||||||
const s = await (await req('/api/status', { headers: headers(false) })).json();
|
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)); }
|
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) || ''; }
|
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
|
||||||
$('#queue').replaceChildren(...s.queued.map(li));
|
$('#queue').replaceChildren(...s.queued.map(li));
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ PUID=$(id -u)
|
||||||
PGID=$(id -g)
|
PGID=$(id -g)
|
||||||
XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
|
XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
|
||||||
BIND_IP=$BIND_IP
|
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_PORT=${ANNOUNCER_PORT:-8090}
|
||||||
ANNOUNCER_SINK=${ANNOUNCER_SINK:-}
|
ANNOUNCER_SINK=${ANNOUNCER_SINK:-}
|
||||||
ANNOUNCER_TITLE=${ANNOUNCER_TITLE:-}
|
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():
|
def test_rolle_til_handling():
|
||||||
cfg = config.load("/finnes-ikke.toml")
|
cfg = config.load("/finnes-ikke.toml")
|
||||||
assert cfg.permissions(("admin",)) == frozenset(config.ACTIONS)
|
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(("ukjent-rolle",)) == frozenset()
|
||||||
assert cfg.permissions(()) == frozenset()
|
assert cfg.permissions(()) == frozenset()
|
||||||
# Roller legges sammen
|
# Roller legges sammen
|
||||||
cfg.data["roles"]["bare_stopp"] = ["stop"]
|
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():
|
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