Add Timepoll availability polling application and image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:timepoll oci_labels:org.opencontai… (push) Successful in 44s

This commit is contained in:
ajp_anton
2026-09-09 19:39:40 +00:00
parent cb80d420c0
commit 4cc2e8fc80
21 changed files with 3519 additions and 2 deletions
+501
View File
@@ -0,0 +1,501 @@
import asyncio
import hashlib
import json
import os
import secrets
import sqlite3
import time
import unicodedata
from collections import defaultdict, deque
from contextlib import contextmanager
from itertools import pairwise
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
from domain import make_slots, remap_votes, resize_slots, summaries, votes_lost
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
ROOT = Path(__file__).parent
def normalize_code(value):
return (
"".join(value.upper().split())
.replace("-", "")
.translate(str.maketrans("OIL", "011"))
)
def new_code(length):
return "".join(secrets.choice(ALPHABET) for _ in range(length))
class Schedule(BaseModel):
dates: list[str] = Field(max_length=366)
start: str = "09:00"
end: str = "17:00"
minutes: int = 30
timezone: str
@field_validator("minutes")
@classmethod
def slot_size(cls, value):
if value not in (15, 30, 60):
raise ValueError("Choose 15, 30 or 60 minutes.")
return value
@field_validator("timezone")
@classmethod
def zone(cls, value):
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError):
raise ValueError("Choose an IANA location timezone.") from None
return value
class PollSettings(BaseModel):
title: str = Field(min_length=1, max_length=120)
timezone: str
fixed_timezone: bool = False
minutes: int = 30
slots: list[int] = Field(min_length=1, max_length=12000)
blocked: list[int] = Field(default_factory=list, max_length=12000)
settings_revision: int = 0
confirm_loss: bool = False
_size = field_validator("minutes")(Schedule.slot_size.__func__)
_zone = field_validator("timezone")(Schedule.zone.__func__)
@field_validator("title")
@classmethod
def title_valid(cls, value):
value = value.strip()
if not value:
raise ValueError("Enter an event name.")
return value
class Votes(BaseModel):
name: str = Field(min_length=1, max_length=80)
votes: dict[str, str] = Field(default_factory=dict, max_length=12000)
revision: int = 0
schedule_revision: int
def create_app(data_dir=None):
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
data = Path(data_dir or os.environ.get("TIMEPOLL_DATA_DIR", "/data"))
data.mkdir(parents=True, exist_ok=True)
database = data / "timepoll.sqlite3"
@contextmanager
def db(write=False):
con = sqlite3.connect(database, timeout=10, isolation_level=None)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys=ON")
try:
con.execute("BEGIN IMMEDIATE" if write else "BEGIN")
yield con
con.commit()
except BaseException:
con.rollback()
raise
finally:
con.close()
with sqlite3.connect(database) as con:
con.execute("PRAGMA journal_mode=WAL")
con.executescript("""
CREATE TABLE IF NOT EXISTS polls (
code TEXT PRIMARY KEY, admin_hash TEXT NOT NULL UNIQUE,
title TEXT NOT NULL, timezone TEXT NOT NULL, fixed_timezone INTEGER NOT NULL,
minutes INTEGER NOT NULL, slots TEXT NOT NULL, blocked TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1, schedule_revision INTEGER NOT NULL DEFAULT 1,
settings_revision INTEGER NOT NULL DEFAULT 1);
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY, poll TEXT NOT NULL REFERENCES polls(code) ON DELETE CASCADE,
name TEXT NOT NULL, name_key TEXT NOT NULL, votes TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1, UNIQUE(poll, name_key));
""")
limits = defaultdict(deque)
@app.middleware("http")
async def guards(request, call_next):
if request.method in ("POST", "PUT", "DELETE"):
origin = request.headers.get("origin")
if origin and origin != str(request.base_url).rstrip("/"):
return JSONResponse(
{"detail": "Cross-origin writes are not allowed."}, 403
)
# Limit actual bytes, including chunked requests without Content-Length.
body = bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body) > 1024 * 1024:
return JSONResponse({"detail": "Request too large."}, 413)
request._body = bytes(body)
if request.url.path.startswith("/api/") and not request.url.path.endswith(
"/events"
):
now = time.monotonic()
# Bound memory and expire idle addresses, including rate-limit keys.
for key in list(limits):
if not limits[key] or limits[key][-1] < now - 3600:
del limits[key]
creating = request.method == "POST" and request.url.path == "/api/polls"
key = (request.client.host, creating)
window, maximum = (3600, 20) if creating else (60, 240)
queue = limits[key]
while queue and queue[0] <= now - window:
queue.popleft()
if len(queue) >= maximum:
return JSONResponse(
{"detail": "Too many requests. Please try again later."},
429,
headers={"Retry-After": str(window)},
)
queue.append(now)
response = await call_next(request)
response.headers.update(
{
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
}
)
if request.url.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store"
return response
def load(con, code):
row = con.execute(
"SELECT * FROM polls WHERE code=?", (normalize_code(code),)
).fetchone()
if row is None:
raise HTTPException(404, "Poll not found. Check the event code.")
poll = dict(row)
poll["slots"] = json.loads(poll["slots"])
poll["blocked"] = json.loads(poll["blocked"])
poll["fixed_timezone"] = bool(poll["fixed_timezone"])
return poll
def people(con, code):
return [
{
"id": r["id"],
"name": r["name"],
"votes": json.loads(r["votes"]),
"revision": r["revision"],
}
for r in con.execute(
"SELECT * FROM people WHERE poll=? ORDER BY id", (code,)
)
]
def authorize(request, poll):
token = normalize_code(
request.headers.get("Authorization", "").removeprefix("Bearer ")
)
if not secrets.compare_digest(
hashlib.sha256(token.encode()).hexdigest(), poll["admin_hash"]
):
raise HTTPException(403, "The private admin link is required.")
def check_settings(settings):
slots = sorted(set(settings.slots))
if any(s < 0 or s > 4102444800 or s % 60 for s in slots):
raise HTTPException(
422, "Slots must be minute-aligned dates between 1970 and 2100."
)
if any(b - a < settings.minutes * 60 for a, b in pairwise(slots)):
raise HTTPException(422, "Time slots cannot overlap.")
if not set(settings.blocked) <= set(slots):
raise HTTPException(422, "Blocked times must belong to the schedule.")
return slots, sorted(set(settings.blocked))
@app.get("/healthz")
def health():
with db() as con:
con.execute("SELECT 1 FROM polls LIMIT 1").fetchone()
return {"status": "ok"}
@app.get("/api/timezones")
def zones():
return sorted(available_timezones())
@app.post("/api/schedule")
def preview(schedule: Schedule):
try:
slots = make_slots(
schedule.dates,
schedule.start,
schedule.end,
schedule.minutes,
schedule.timezone,
)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
if not slots or len(slots) > 12000:
raise HTTPException(422, "Choose between 1 and 12,000 slots.")
return {"slots": slots}
@app.post("/api/resize")
def resize(settings: PollSettings, old_minutes: int):
if old_minutes not in (15, 30, 60):
raise HTTPException(422, "Invalid previous slot size.")
slots = resize_slots(
sorted(set(settings.slots)),
old_minutes,
settings.minutes,
settings.timezone,
)
blocked = remap_votes(
{str(s): "yes" for s in settings.blocked},
old_minutes,
slots,
settings.minutes,
)
return {"slots": slots, "blocked": [int(s) for s in blocked]}
@app.post("/api/polls", status_code=201)
def create(settings: PollSettings):
slots, blocked = check_settings(settings)
with db(True) as con:
while True:
code, token = new_code(9), new_code(12)
digest = hashlib.sha256(token.encode()).hexdigest()
try:
con.execute(
"INSERT INTO polls(code,admin_hash,title,timezone,fixed_timezone,minutes,slots,blocked) VALUES(?,?,?,?,?,?,?,?)",
(
code,
digest,
settings.title,
settings.timezone,
settings.fixed_timezone,
settings.minutes,
json.dumps(slots),
json.dumps(blocked),
),
)
break
except sqlite3.IntegrityError:
if (
con.execute(
"SELECT 1 FROM polls WHERE code=? OR admin_hash=?",
(code, digest),
).fetchone()
is None
):
raise
return {"code": code, "admin_token": token}
@app.get("/api/polls/{code}")
def get_poll(code: str):
with db() as con:
poll = load(con, code)
poll.pop("admin_hash")
poll["people"] = people(con, poll["code"])
active = sorted(set(poll["slots"]) - set(poll["blocked"]))
poll["summaries"] = summaries(active, poll["minutes"], poll["people"])
return poll
@app.get("/api/polls/{code}/admin")
def admin(code: str, request: Request):
with db() as con:
authorize(request, load(con, code))
return {"authorized": True}
@app.post("/api/admin/resolve")
def resolve_admin(request: Request):
token = normalize_code(
request.headers.get("Authorization", "").removeprefix("Bearer ")
)
with db() as con:
row = con.execute(
"SELECT code FROM polls WHERE admin_hash=?",
(hashlib.sha256(token.encode()).hexdigest(),),
).fetchone()
if row is None:
raise HTTPException(403, "The private admin link is invalid.")
return {"code": row["code"]}
@app.put("/api/polls/{code}")
def update(code: str, settings: PollSettings, request: Request):
slots, blocked = check_settings(settings)
with db(True) as con:
poll = load(con, code)
authorize(request, poll)
if settings.settings_revision != poll["settings_revision"]:
raise HTTPException(
409, "This poll was edited elsewhere. Reload before saving."
)
active = sorted(set(slots) - set(blocked))
participants = people(con, poll["code"])
if not settings.confirm_loss and any(
votes_lost(p["votes"], poll["minutes"], active, settings.minutes)
for p in participants
):
raise HTTPException(
409,
{
"code": "vote_loss",
"message": "Some existing votes will be lost. Save these changes?",
},
)
changed = (
slots != poll["slots"]
or blocked != poll["blocked"]
or settings.minutes != poll["minutes"]
)
if changed:
for person in participants:
votes = remap_votes(
person["votes"], poll["minutes"], active, settings.minutes
)
con.execute(
"UPDATE people SET votes=?, revision=revision+1 WHERE id=?",
(json.dumps(votes), person["id"]),
)
con.execute(
"UPDATE polls SET title=?, timezone=?, fixed_timezone=?, minutes=?, slots=?, blocked=?, revision=revision+1, settings_revision=settings_revision+1, schedule_revision=schedule_revision+? WHERE code=?",
(
settings.title,
settings.timezone,
settings.fixed_timezone,
settings.minutes,
json.dumps(slots),
json.dumps(blocked),
int(changed),
poll["code"],
),
)
return {"saved": True}
def save_person(code, body, person_id=None):
name = " ".join(body.name.split())
if not name:
raise HTTPException(422, "Enter your name.")
name_key = unicodedata.normalize("NFKC", name).casefold()
with db(True) as con:
poll = load(con, code)
if body.schedule_revision != poll["schedule_revision"]:
raise HTTPException(
409,
"The available times changed. Cancel and review the new schedule before saving.",
)
active = {str(s) for s in set(poll["slots"]) - set(poll["blocked"])}
if not set(body.votes) <= active or any(
v not in ("yes", "maybe") for v in body.votes.values()
):
raise HTTPException(
422, "Votes must refer to available slots and use yes or maybe."
)
if person_id is not None:
person = con.execute(
"SELECT * FROM people WHERE id=? AND poll=?",
(person_id, poll["code"]),
).fetchone()
if person is None or person["revision"] != body.revision:
raise HTTPException(
409,
"This participant was changed or removed elsewhere. Cancel and reload their votes.",
)
elif (
con.execute(
"SELECT count(*) FROM people WHERE poll=?", (poll["code"],)
).fetchone()[0]
>= 200
):
raise HTTPException(
422, "This poll has reached its limit of 200 participants."
)
try:
if person_id is None:
person_id = con.execute(
"INSERT INTO people(poll,name,name_key,votes) VALUES(?,?,?,?)",
(poll["code"], name, name_key, json.dumps(body.votes)),
).lastrowid
else:
con.execute(
"UPDATE people SET name=?, name_key=?, votes=?, revision=revision+1 WHERE id=?",
(name, name_key, json.dumps(body.votes), person_id),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(
409, "That name is already in this poll. Choose a different name."
) from exc
con.execute(
"UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],)
)
return {"id": person_id}
@app.post("/api/polls/{code}/people", status_code=201)
def add_person(code: str, body: Votes):
return save_person(code, body)
@app.put("/api/polls/{code}/people/{person_id}")
def edit_person(code: str, person_id: int, body: Votes):
return save_person(code, body, person_id)
@app.delete("/api/polls/{code}/people/{person_id}")
def delete_person(code: str, person_id: int, revision: int):
with db(True) as con:
poll = load(con, code)
cursor = con.execute(
"DELETE FROM people WHERE poll=? AND id=? AND revision=?",
(poll["code"], person_id, revision),
)
if not cursor.rowcount:
raise HTTPException(
409,
"This participant was changed or removed elsewhere. Reload before removing them.",
)
con.execute(
"UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],)
)
return {"removed": True}
@app.get("/api/polls/{code}/events")
async def events(code: str, request: Request):
def revision():
with db() as con:
return load(con, code)["revision"]
await asyncio.to_thread(revision)
async def stream():
previous = None
while not await request.is_disconnected():
current = await asyncio.to_thread(revision)
if current != previous:
yield f"data: {current}\n\n"
previous = current
else:
yield ": heartbeat\n\n"
await asyncio.sleep(2)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
@app.get("/")
@app.get("/new")
@app.get("/{link_code}")
def page():
return FileResponse(
ROOT / "static" / "index.html", headers={"Cache-Control": "no-cache"}
)
return app