Wichtel-Werkstatt: Secret-Santa-App mit Räumen, Ausschlüssen und Recovery-Links

- FastAPI + SQLite, serverseitig gerenderte Templates, mobil-zuerst
- Admin-Passwort, einseitige Ausschlüsse, eingefrorene Auslosung
- Teilnehmer sehen Ergebnis per Cookie; Einmal-Recovery-Links bei Verlust
- Docker/podman-tauglich (Entrypoint mit Privilegien-Drop, SELinux-:z)
- Unit-Tests für Auslosung, E2E-Testskript (30 Checks)
This commit is contained in:
2026-08-05 16:07:21 +02:00
commit d56be7b2c0
21 changed files with 1839 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
"""SQLite-Zugriff für die Wichtel-Werkstatt.
Eine Datei, ein Volume: Die komplette Anwendung lebt in einer SQLite-Datei
unter $WICHTELN_DATA_DIR (Default: ./data). Pro Request wird eine eigene
Verbindung geöffnet bei der erwarteten Last (Familien/Freunde) ist das
robuster als eine geteilte Verbindung.
"""
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
import sqlite3
from datetime import datetime, timezone
DB_DIR = os.environ.get("WICHTELN_DATA_DIR", "./data")
DB_PATH = os.path.join(DB_DIR, "wichteln.db")
# 6-stelliger Raumcode ohne mehrdeutige Zeichen (kein 0/O, 1/I/L).
CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
CODE_LENGTH = 6
_SCHEMA = """
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
admin_token_hash TEXT,
created_at TEXT NOT NULL,
drawn_at TEXT
);
CREATE TABLE IF NOT EXISTS participants (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
name TEXT NOT NULL COLLATE NOCASE,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (room_id, name)
);
CREATE TABLE IF NOT EXISTS exclusions (
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
from_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
to_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
PRIMARY KEY (room_id, from_id, to_id)
);
CREATE TABLE IF NOT EXISTS assignments (
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
giver_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
receiver_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
PRIMARY KEY (room_id, giver_id),
UNIQUE (room_id, receiver_id)
);
CREATE TABLE IF NOT EXISTS recovery_links (
token_hash TEXT PRIMARY KEY,
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
used_at TEXT
);
"""
def connect() -> sqlite3.Connection:
os.makedirs(DB_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
return conn
def init_db() -> None:
with connect() as conn:
conn.executescript(_SCHEMA)
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def new_id() -> str:
return secrets.token_urlsafe(12)
def new_token() -> str:
return secrets.token_urlsafe(24)
def hash_token(token: str) -> str:
"""SHA-256 reicht für hochentropische Zufalls-Tokens."""
return hashlib.sha256(token.encode()).hexdigest()
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 390_000)
return f"{salt.hex()}${dk.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
salt_hex, dk_hex = stored.split("$", 1)
except ValueError:
return False
salt = bytes.fromhex(salt_hex)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 390_000)
return hmac.compare_digest(dk.hex(), dk_hex)
def new_room_code(conn: sqlite3.Connection) -> str:
while True:
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
row = conn.execute("SELECT 1 FROM rooms WHERE code = ?", (code,)).fetchone()
if row is None:
return code
# ---------- Räume ----------
def create_room(conn: sqlite3.Connection, name: str, password: str) -> sqlite3.Row:
room_id = new_id()
code = new_room_code(conn)
conn.execute(
"INSERT INTO rooms (id, code, name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
(room_id, code, name.strip(), hash_password(password), now_iso()),
)
return get_room_by_id(conn, room_id)
def get_room_by_id(conn: sqlite3.Connection, room_id: str) -> sqlite3.Row | None:
return conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
def get_room_by_code(conn: sqlite3.Connection, code: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM rooms WHERE code = ?", (code.strip().upper(),)
).fetchone()
def set_admin_token(conn: sqlite3.Connection, room_id: str, token: str) -> None:
conn.execute(
"UPDATE rooms SET admin_token_hash = ? WHERE id = ?",
(hash_token(token), room_id),
)
def clear_admin_token(conn: sqlite3.Connection, room_id: str) -> None:
conn.execute("UPDATE rooms SET admin_token_hash = NULL WHERE id = ?", (room_id,))
def room_is_drawn(room: sqlite3.Row) -> bool:
return room["drawn_at"] is not None
# ---------- Teilnehmer ----------
def add_participant(conn: sqlite3.Connection, room_id: str, name: str, token: str) -> sqlite3.Row:
pid = new_id()
conn.execute(
"INSERT INTO participants (id, room_id, name, token_hash, created_at) VALUES (?, ?, ?, ?, ?)",
(pid, room_id, name.strip(), hash_token(token), now_iso()),
)
return get_participant(conn, pid)
def get_participant(conn: sqlite3.Connection, participant_id: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM participants WHERE id = ?", (participant_id,)
).fetchone()
def set_participant_token(conn: sqlite3.Connection, participant_id: str, token: str) -> None:
"""Neues Token setzen, z. B. wenn ein Cookie verloren ging (Recovery-Link)."""
conn.execute(
"UPDATE participants SET token_hash = ? WHERE id = ?",
(hash_token(token), participant_id),
)
def get_participant_by_token(conn: sqlite3.Connection, room_id: str, token: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM participants WHERE room_id = ? AND token_hash = ?",
(room_id, hash_token(token)),
).fetchone()
def list_participants(conn: sqlite3.Connection, room_id: str) -> list[sqlite3.Row]:
return conn.execute(
"SELECT * FROM participants WHERE room_id = ? ORDER BY created_at",
(room_id,),
).fetchall()
def delete_participant(conn: sqlite3.Connection, room_id: str, participant_id: str) -> None:
conn.execute(
"DELETE FROM participants WHERE id = ? AND room_id = ?",
(participant_id, room_id),
)
# ---------- Ausschlüsse ----------
def add_exclusion(conn: sqlite3.Connection, room_id: str, from_id: str, to_id: str) -> None:
conn.execute(
"INSERT OR IGNORE INTO exclusions (room_id, from_id, to_id) VALUES (?, ?, ?)",
(room_id, from_id, to_id),
)
def remove_exclusion(conn: sqlite3.Connection, room_id: str, from_id: str, to_id: str) -> None:
conn.execute(
"DELETE FROM exclusions WHERE room_id = ? AND from_id = ? AND to_id = ?",
(room_id, from_id, to_id),
)
def list_exclusions(conn: sqlite3.Connection, room_id: str) -> list[sqlite3.Row]:
return conn.execute(
"""SELECT e.from_id, e.to_id, pf.name AS from_name, pt.name AS to_name
FROM exclusions e
JOIN participants pf ON pf.id = e.from_id
JOIN participants pt ON pt.id = e.to_id
WHERE e.room_id = ?
ORDER BY pf.name, pt.name""",
(room_id,),
).fetchall()
def exclusion_pairs(conn: sqlite3.Connection, room_id: str) -> set[tuple[str, str]]:
rows = conn.execute(
"SELECT from_id, to_id FROM exclusions WHERE room_id = ?", (room_id,)
).fetchall()
return {(r["from_id"], r["to_id"]) for r in rows}
# ---------- Auslosung ----------
def save_assignments(conn: sqlite3.Connection, room_id: str, mapping: dict[str, str]) -> None:
conn.executemany(
"INSERT INTO assignments (room_id, giver_id, receiver_id) VALUES (?, ?, ?)",
[(room_id, giver, receiver) for giver, receiver in mapping.items()],
)
conn.execute("UPDATE rooms SET drawn_at = ? WHERE id = ?", (now_iso(), room_id))
def get_assignment_for(conn: sqlite3.Connection, room_id: str, giver_id: str) -> sqlite3.Row | None:
return conn.execute(
"""SELECT a.receiver_id, p.name AS receiver_name
FROM assignments a JOIN participants p ON p.id = a.receiver_id
WHERE a.room_id = ? AND a.giver_id = ?""",
(room_id, giver_id),
).fetchone()
# ---------- Recovery-Links ----------
def create_recovery_link(conn: sqlite3.Connection, room_id: str, participant_id: str, token: str) -> None:
conn.execute(
"DELETE FROM recovery_links WHERE participant_id = ? AND used_at IS NULL",
(participant_id,),
)
conn.execute(
"INSERT INTO recovery_links (token_hash, room_id, participant_id, created_at) VALUES (?, ?, ?, ?)",
(hash_token(token), room_id, participant_id, now_iso()),
)
def get_recovery_link(conn: sqlite3.Connection, token: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM recovery_links WHERE token_hash = ?", (hash_token(token),)
).fetchone()
def mark_recovery_link_used(conn: sqlite3.Connection, token_hash: str) -> None:
conn.execute(
"UPDATE recovery_links SET used_at = ? WHERE token_hash = ?",
(now_iso(), token_hash),
)
+41
View File
@@ -0,0 +1,41 @@
"""Auslosung: zufällige Zuordnung, bei der niemand sich selbst zieht
und einseitige Ausschlüsse respektiert werden.
Ansatz: Rejection-Sampling über zufällige Permutationen. Für typische
Gruppen (Familien/Freunde, < 30 Personen) findet sich eine gültige
Permutation fast immer in wenigen Versuchen (nur die Derangement-
Bedingung allein hat bereits Erfolgswahrscheinlichkeit ~1/e).
"""
from __future__ import annotations
import random
MAX_ATTEMPTS = 2000
def draw_assignment(
participant_ids: list[str],
exclusions: set[tuple[str, str]],
*,
rng: random.Random | None = None,
max_attempts: int = MAX_ATTEMPTS,
) -> dict[str, str] | None:
"""Liefert {giver_id: receiver_id} oder None, wenn keine gültige
Zuordnung gefunden wurde (z. B. unerfüllbare Ausschlüsse)."""
rng = rng or random.Random()
ids = list(participant_ids)
n = len(ids)
if n < 2:
return None
for _ in range(max_attempts):
receivers = ids[:]
rng.shuffle(receivers)
if any(receivers[i] == ids[i] for i in range(n)):
continue
mapping = dict(zip(ids, receivers))
if any((giver, receiver) in exclusions for giver, receiver in mapping.items()):
continue
return mapping
return None
+496
View File
@@ -0,0 +1,496 @@
"""Wichtel-Werkstatt FastAPI-Anwendung.
Routen:
/ Raum erstellen oder per Code beitreten
/r/<code> Teilnehmeransicht (Registrierung / Status / Ergebnis)
/r/<code>/admin Admin-Login bzw. Admin-Panel
/r/<code>/recover/<token> Einmal-Link, um ein verlorenes Cookie zu ersetzen
"""
from __future__ import annotations
import sqlite3
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import db
from .draw import draw_assignment
BASE_DIR = Path(__file__).resolve().parent
COOKIE_MAX_AGE = 2 * 365 * 24 * 60 * 60 # 2 Jahre
MIN_PASSWORD_LENGTH = 4
MIN_NAME_LENGTH = 1
MAX_NAME_LENGTH = 50
MAX_ROOM_NAME_LENGTH = 60
MIN_PARTICIPANTS_FOR_DRAW = 3
@asynccontextmanager
async def lifespan(_: FastAPI):
db.init_db()
yield
app = FastAPI(title="Wichtel-Werkstatt", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=BASE_DIR / "templates")
# ---------- Helpers ----------
def conn() -> sqlite3.Connection:
return db.connect()
def is_secure(request: Request) -> bool:
"""Secure-Cookie nur über HTTPS (direkt oder via X-Forwarded-Proto)."""
forwarded = request.headers.get("x-forwarded-proto", "")
return "https" in forwarded or request.url.scheme == "https"
def set_cookie(response, request: Request, name: str, value: str, path: str) -> None:
response.set_cookie(
name,
value,
max_age=COOKIE_MAX_AGE,
httponly=True,
secure=is_secure(request),
samesite="lax",
path=path,
)
def participant_cookie(room_id: str) -> str:
return f"wichtel_p_{room_id}"
def admin_cookie(room_id: str) -> str:
return f"wichtel_a_{room_id}"
def base_url(request: Request) -> str:
return str(request.base_url).rstrip("/")
def share_url(request: Request, code: str) -> str:
return f"{base_url(request)}/r/{code}"
def render_message(request: Request, title: str, text: str, status: int = 200) -> HTMLResponse:
return templates.TemplateResponse(
request, "message.html", {"title": title, "text": text}, status_code=status
)
def room_not_found(request: Request) -> HTMLResponse:
return render_message(
request,
"Raum nicht gefunden 🤔",
"Diesen Wichtel-Raum gibt es nicht (mehr). Prüfe den Link oder Code "
"oder frage den Admin nach dem richtigen Einladungslink.",
status=404,
)
def get_admin_room(request: Request, room) -> bool:
"""True, wenn das Admin-Cookie gültig ist."""
token = request.cookies.get(admin_cookie(room["id"]))
if not token or not room["admin_token_hash"]:
return False
return db.hash_token(token) == room["admin_token_hash"]
def admin_panel_response(request: Request, room, recovery_link: str | None = None,
recovery_for: str | None = None, message: str | None = None,
message_kind: str = "ok"):
with conn() as c:
participants = db.list_participants(c, room["id"])
exclusions = db.list_exclusions(c, room["id"])
admin_participant = None
ptoken = request.cookies.get(participant_cookie(room["id"]))
if ptoken:
admin_participant = db.get_participant_by_token(c, room["id"], ptoken)
drawn = db.room_is_drawn(room)
can_draw = (not drawn) and len(participants) >= MIN_PARTICIPANTS_FOR_DRAW
return templates.TemplateResponse(
request,
"admin.html",
{
"room": room,
"participants": participants,
"exclusions": exclusions,
"share_link": share_url(request, room["code"]),
"drawn": drawn,
"can_draw": can_draw,
"min_draw": MIN_PARTICIPANTS_FOR_DRAW,
"recovery_link": recovery_link,
"recovery_for": recovery_for,
"admin_participant": admin_participant,
"message": message,
"message_kind": message_kind,
"base": base_url(request),
},
)
# ---------- Startseite ----------
HOME_ERRORS = {
"room_name": "Bitte gib einen Raumnamen an (höchstens 60 Zeichen).",
"password": "Das Admin-Passwort muss mindestens 4 Zeichen lang sein.",
"code": "Zu diesem Code wurde kein Raum gefunden.",
}
@app.get("/", response_class=HTMLResponse)
def home(request: Request, error: str | None = None):
return templates.TemplateResponse(
request, "home.html", {"error": HOME_ERRORS.get(error or "")}
)
@app.post("/rooms")
def create_room(request: Request, room_name: str = Form(...), password: str = Form(...)):
room_name = room_name.strip()
if not room_name or len(room_name) > MAX_ROOM_NAME_LENGTH:
return RedirectResponse("/?error=room_name", status_code=303)
if len(password) < MIN_PASSWORD_LENGTH:
return RedirectResponse("/?error=password", status_code=303)
with conn() as c:
room = db.create_room(c, room_name, password)
token = db.new_token()
db.set_admin_token(c, room["id"], token)
response = RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
set_cookie(response, request, admin_cookie(room["id"]), token, f"/r/{room['code']}")
return response
@app.post("/join")
def join(request: Request, code: str = Form(...)):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return RedirectResponse("/?error=code", status_code=303)
return RedirectResponse(f"/r/{room['code']}", status_code=303)
# ---------- Teilnehmeransicht ----------
ROOM_ERRORS = {
"name": "Bitte gib einen Namen an (höchstens 50 Zeichen).",
"duplicate": "Dieser Name ist im Raum schon vergeben wähle bitte einen anderen "
"(z. B. mit Nachnamen).",
}
@app.get("/r/{code}", response_class=HTMLResponse)
def room_page(request: Request, code: str, error: str | None = None):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
participant = None
token = request.cookies.get(participant_cookie(room["id"]))
if token:
participant = db.get_participant_by_token(c, room["id"], token)
assignment = None
if participant and db.room_is_drawn(room):
assignment = db.get_assignment_for(c, room["id"], participant["id"])
return templates.TemplateResponse(
request,
"room.html",
{
"room": room,
"participant": participant,
"assignment": assignment,
"drawn": db.room_is_drawn(room),
"error": ROOM_ERRORS.get(error or ""),
},
)
@app.post("/r/{code}/register")
def register(request: Request, code: str, name: str = Form(...)):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if db.room_is_drawn(room):
return render_message(
request,
"Zu spät 🎄",
"In diesem Raum wurde bereits ausgelost eine Teilnahme ist nicht mehr möglich.",
)
name = name.strip()
if len(name) < MIN_NAME_LENGTH or len(name) > MAX_NAME_LENGTH:
return RedirectResponse(f"/r/{room['code']}?error=name", status_code=303)
token = db.new_token()
try:
participant = db.add_participant(c, room["id"], name, token)
except sqlite3.IntegrityError:
return RedirectResponse(f"/r/{room['code']}?error=duplicate", status_code=303)
response = RedirectResponse(f"/r/{room['code']}", status_code=303)
set_cookie(response, request, participant_cookie(room["id"]), token, f"/r/{room['code']}")
return response
# ---------- Admin ----------
@app.get("/r/{code}/admin", response_class=HTMLResponse)
def admin_page(request: Request, code: str):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not get_admin_room(request, room):
return templates.TemplateResponse(
request, "admin_login.html", {"room": room, "error": None}
)
return admin_panel_response(request, room)
@app.post("/r/{code}/admin/login")
def admin_login(request: Request, code: str, password: str = Form(...)):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not db.verify_password(password, room["password_hash"]):
return templates.TemplateResponse(
request, "admin_login.html", {"room": room, "error": True}
)
token = db.new_token()
db.set_admin_token(c, room["id"], token)
response = RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
set_cookie(response, request, admin_cookie(room["id"]), token, f"/r/{room['code']}")
return response
@app.post("/r/{code}/admin/logout")
def admin_logout(request: Request, code: str):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
db.clear_admin_token(c, room["id"])
response = RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
response.delete_cookie(admin_cookie(room["id"]), path=f"/r/{room['code']}")
return response
@app.post("/r/{code}/admin/self-register")
def admin_self_register(request: Request, code: str, name: str = Form(...)):
"""Der Admin nimmt selbst am Wichteln teil: legt ihn als Teilnehmer an
und setzt zusätzlich den Teilnehmer-Cookie in seinem Browser."""
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not get_admin_room(request, room):
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
if db.room_is_drawn(room):
return admin_panel_response(
request, room,
message="Nach der Auslosung ist keine Teilnahme mehr möglich.",
message_kind="error",
)
name = name.strip()
if len(name) < MIN_NAME_LENGTH or len(name) > MAX_NAME_LENGTH:
return admin_panel_response(
request, room,
message="Bitte gib einen gültigen Namen an (höchstens 50 Zeichen).",
message_kind="error",
)
token = db.new_token()
try:
db.add_participant(c, room["id"], name, token)
except sqlite3.IntegrityError:
return admin_panel_response(
request, room, message="Dieser Name ist bereits vergeben.",
message_kind="error",
)
response = admin_panel_response(
request, room,
message=f"Schön, dass du dabei bist: Du wichtelst als »{name}« mit! 🎅 "
"Deine Teilnehmeransicht (und später dein Ergebnis) erreichst du "
"über den Einladungslink.",
message_kind="ok",
)
set_cookie(response, request, participant_cookie(room["id"]), token, f"/r/{room['code']}")
return response
@app.post("/r/{code}/participants/{pid}/delete")
def delete_participant(request: Request, code: str, pid: str):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not get_admin_room(request, room):
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
if db.room_is_drawn(room):
return admin_panel_response(
request, room, message="Nach der Auslosung können keine Teilnehmer mehr gelöscht werden.",
message_kind="error",
)
db.delete_participant(c, room["id"], pid)
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
@app.post("/r/{code}/exclusions/add")
def add_exclusion(request: Request, code: str, from_id: str = Form(...), to_id: str = Form(...)):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not get_admin_room(request, room):
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
if db.room_is_drawn(room):
return admin_panel_response(
request, room, message="Nach der Auslosung können keine Ausschlüsse mehr geändert werden.",
message_kind="error",
)
if from_id == to_id:
return admin_panel_response(
request, room, message="Ein Teilnehmer kann sich nicht selbst ausgeschlossen werden.",
message_kind="error",
)
db.add_exclusion(c, room["id"], from_id, to_id)
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
@app.post("/r/{code}/exclusions/{from_id}/{to_id}/delete")
def delete_exclusion(request: Request, code: str, from_id: str, to_id: str):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not get_admin_room(request, room):
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
if db.room_is_drawn(room):
return admin_panel_response(
request, room, message="Nach der Auslosung können keine Ausschlüsse mehr geändert werden.",
message_kind="error",
)
db.remove_exclusion(c, room["id"], from_id, to_id)
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
@app.post("/r/{code}/draw")
def draw(request: Request, code: str):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not get_admin_room(request, room):
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
if db.room_is_drawn(room):
return admin_panel_response(
request, room, message="Es wurde bereits ausgelost.", message_kind="error"
)
participants = db.list_participants(c, room["id"])
if len(participants) < MIN_PARTICIPANTS_FOR_DRAW:
return admin_panel_response(
request, room,
message=f"Mindestens {MIN_PARTICIPANTS_FOR_DRAW} Teilnehmer nötig aktuell sind es {len(participants)}.",
message_kind="error",
)
ids = [p["id"] for p in participants]
mapping = draw_assignment(ids, db.exclusion_pairs(c, room["id"]))
if mapping is None:
return admin_panel_response(
request, room,
message="Mit diesen Ausschlüssen ist keine gültige Auslosung möglich. "
"Bitte entferne oder lockere Ausschlüsse.",
message_kind="error",
)
db.save_assignments(c, room["id"], mapping)
room = db.get_room_by_id(c, room["id"])
return admin_panel_response(
request, room,
message="🎉 Die Auslosung ist abgeschlossen! Die Teilnehmer sehen ihr Ergebnis, "
"wenn sie ihren Einladungslink öffnen.",
message_kind="ok",
)
# ---------- Recovery-Links (Cookie verloren) ----------
@app.post("/r/{code}/recovery/{pid}")
def create_recovery_link(request: Request, code: str, pid: str):
"""Erzeugt einen Einmal-Link, mit dem der Teilnehmer sein Cookie zurückbekommt.
Bewusst auch nach der Auslosung erlaubt: Der Admin sieht dabei weiterhin
nicht, wer wen gezogen hat der Link stellt nur die Sicht des Teilnehmers
selbst wieder her.
"""
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
if not get_admin_room(request, room):
return RedirectResponse(f"/r/{room['code']}/admin", status_code=303)
participant = db.get_participant(c, pid)
if participant is None or participant["room_id"] != room["id"]:
return admin_panel_response(
request, room, message="Teilnehmer nicht gefunden.", message_kind="error"
)
token = db.new_token()
db.create_recovery_link(c, room["id"], pid, token)
link = f"{share_url(request, room['code'])}/recover/{token}"
return admin_panel_response(
request, room, recovery_link=link, recovery_for=participant["name"],
message=None,
)
@app.get("/r/{code}/recover/{token}", response_class=HTMLResponse)
def redeem_recovery_link(request: Request, code: str, token: str):
with conn() as c:
room = db.get_room_by_code(c, code)
if room is None:
return room_not_found(request)
link = db.get_recovery_link(c, token)
if link is None or link["room_id"] != room["id"]:
return render_message(
request,
"Link ungültig 🔒",
"Dieser Wiederherstellungslink ist ungültig. Bitte den Admin um einen neuen Link.",
status=404,
)
if link["used_at"] is not None:
return render_message(
request,
"Link bereits verwendet 🔒",
"Dieser Wiederherstellungslink wurde schon benutzt. "
"Falls du erneut Zugriff brauchst, bitte den Admin um einen neuen Link.",
)
new_token = db.new_token()
db.set_participant_token(c, link["participant_id"], new_token)
db.mark_recovery_link_used(c, link["token_hash"])
response = RedirectResponse(f"/r/{room['code']}", status_code=303)
set_cookie(response, request, participant_cookie(room["id"]), new_token, f"/r/{room['code']}")
return response
@app.exception_handler(sqlite3.Error)
def sqlite_error(request: Request, exc: sqlite3.Error):
return render_message(
request,
"Ups ❄",
"Es ist ein Fehler beim Speichern aufgetreten. Bitte versuche es noch einmal.",
status=500,
)
+37
View File
@@ -0,0 +1,37 @@
// Wichtel-Werkstatt: Copy-Buttons und Bestätigungsdialoge.
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-copy]");
if (!button) return;
const source = document.querySelector(button.dataset.copy);
if (!source) return;
const text = source.value || source.textContent;
const done = () => {
const original = button.textContent;
button.textContent = "Kopiert ✓";
setTimeout(() => { button.textContent = original; }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(source, done));
} else {
fallbackCopy(source, done);
}
});
function fallbackCopy(source, done) {
source.focus();
source.select();
try {
document.execCommand("copy");
done();
} catch {
// Kopieren nicht möglich Text bleibt im Feld zum manuellen Markieren.
}
}
document.addEventListener("submit", (event) => {
const form = event.target.closest("form[data-confirm]");
if (form && !window.confirm(form.dataset.confirm)) {
event.preventDefault();
}
});
+281
View File
@@ -0,0 +1,281 @@
/* Wichtel-Werkstatt mobil-zuerst, weihnachtliche Farbpalette */
:root {
--green: #1d5c3f;
--green-dark: #123f2b;
--red: #a4243b;
--red-dark: #831b2e;
--gold: #c9a227;
--gold-soft: #f3e6c0;
--cream: #faf4e8;
--card: #ffffff;
--ink: #2f2a26;
--muted: #6f675e;
--line: #e6dccb;
--radius: 14px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 1rem;
line-height: 1.55;
color: var(--ink);
background-color: var(--cream);
/* dezente Schneeflocken-Tupfer */
background-image:
radial-gradient(circle at 12% 18%, rgba(255, 255, 255, 0.9) 0 3px, transparent 4px),
radial-gradient(circle at 78% 8%, rgba(255, 255, 255, 0.8) 0 2px, transparent 3px),
radial-gradient(circle at 55% 42%, rgba(255, 255, 255, 0.7) 0 2px, transparent 3px),
radial-gradient(circle at 28% 72%, rgba(255, 255, 255, 0.8) 0 3px, transparent 4px),
radial-gradient(circle at 90% 60%, rgba(255, 255, 255, 0.7) 0 2px, transparent 3px),
linear-gradient(180deg, #f4e9d5 0%, var(--cream) 240px);
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* ---------- Kopf & Fuß ---------- */
.site-header {
padding: 0.9rem 1rem 0.4rem;
text-align: center;
}
.brand {
font-size: 1.35rem;
font-weight: 800;
color: var(--red);
text-decoration: none;
letter-spacing: 0.02em;
}
.site-footer {
margin-top: auto;
padding: 1.4rem 1rem 1.8rem;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
main {
width: 100%;
max-width: 30rem;
margin: 0 auto;
padding: 0.6rem 0.9rem 1.5rem;
flex: 1;
}
/* ---------- Typo & Bausteine ---------- */
h1 { font-size: 1.5rem; line-height: 1.25; margin: 0.8rem 0 0.6rem; color: var(--green-dark); }
h2 { font-size: 1.12rem; margin: 0 0 0.6rem; color: var(--green-dark); }
.hero { text-align: center; margin: 0.4rem 0 1rem; }
.hero h1 { font-size: 1.9rem; color: var(--red); }
.hero p { color: var(--muted); margin: 0 auto; max-width: 26rem; }
.room-title { text-align: center; }
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(90, 60, 20, 0.08);
padding: 1.1rem 1rem;
margin: 0.9rem 0;
}
.card-highlight {
border-color: var(--gold);
background: linear-gradient(180deg, #fffdf4, var(--card));
}
.card-result { text-align: center; }
.divider {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--muted);
font-size: 0.85rem;
margin: 1.1rem 0.4rem;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--line);
}
.hint { color: var(--muted); font-size: 0.85rem; }
.hello { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.3rem; }
.waiting { margin-top: 0.4rem; }
.result-label { margin: 0.8rem 0 0.2rem; color: var(--muted); }
.result-name {
font-size: 1.7rem;
font-weight: 800;
color: var(--red);
background: var(--gold-soft);
border: 2px dashed var(--gold);
border-radius: var(--radius);
padding: 0.8rem 0.6rem;
margin: 0.4rem 0 0.9rem;
overflow-wrap: anywhere;
}
/* ---------- Formulare ---------- */
.stack { display: flex; flex-direction: column; gap: 0.8rem; }
label { display: flex; flex-direction: column; gap: 0.25rem; font-weight: 600; font-size: 0.95rem; }
label small { font-weight: 400; color: var(--muted); }
input[type="text"],
input[type="password"],
select {
width: 100%;
min-height: 48px;
padding: 0.55rem 0.8rem;
font-size: 1rem;
font-family: inherit;
color: var(--ink);
background: #fff;
border: 1.5px solid var(--line);
border-radius: 10px;
}
input:focus, select:focus {
outline: 2px solid var(--green);
outline-offset: 1px;
border-color: var(--green);
}
input[readonly] { background: var(--cream); color: var(--green-dark); font-size: 0.9rem; }
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 48px;
padding: 0.6rem 1.2rem;
font-size: 1rem;
font-weight: 700;
font-family: inherit;
border: none;
border-radius: 12px;
cursor: pointer;
text-decoration: none;
transition: transform 0.05s ease, background-color 0.15s ease;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--green); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--green-dark); }
.btn-secondary { background: var(--red); color: #fff; }
.btn-secondary:hover:not(:disabled) { background: var(--red-dark); }
.btn-danger { background: var(--red); color: #fff; }
.btn-danger:hover:not(:disabled) { background: var(--red-dark); }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1.5px solid var(--line);
}
.btn-big { width: 100%; min-height: 56px; font-size: 1.1rem; }
.btn-small { min-height: 38px; padding: 0.3rem 0.75rem; font-size: 0.9rem; }
.logout-row { display: flex; justify-content: center; margin: 1.2rem 0 0.4rem; }
/* ---------- Listen & Admin ---------- */
.admin-head {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.tag {
display: inline-block;
padding: 0.15rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
background: var(--green);
color: #fff;
}
.tag-gold { background: var(--gold); color: #3d2f05; }
.list { list-style: none; margin: 0.5rem 0 0; padding: 0; }
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.55rem 0.2rem;
border-bottom: 1px solid var(--line);
}
.list-item:last-child { border-bottom: none; }
.list-item form { margin: 0; }
.list-name { overflow-wrap: anywhere; }
.copy-row { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
.copy-row input { flex: 1; min-width: 0; }
.code-line { margin: 0.6rem 0 0; color: var(--muted); }
.code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--green-dark);
background: var(--gold-soft);
border-radius: 8px;
padding: 0.15rem 0.55rem;
}
.exclusion-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin: 0.7rem 0 0.4rem;
}
.exclusion-form .btn { grid-column: 1 / -1; }
/* ---------- Meldungen ---------- */
.alert {
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
margin: 0.8rem 0;
font-size: 0.95rem;
}
.alert-ok { background: #e7f2ea; border: 1px solid var(--green); color: var(--green-dark); }
.alert-error { background: #fae8ec; border: 1px solid var(--red); color: var(--red-dark); }
.card-message { text-align: center; }
.card-message .btn { margin-top: 0.8rem; }
/* ---------- Desktop ---------- */
@media (min-width: 640px) {
main { padding-top: 1.2rem; }
.card { padding: 1.4rem 1.4rem; }
.hero h1 { font-size: 2.3rem; }
.exclusion-form { grid-template-columns: 1fr 1fr auto; }
.exclusion-form .btn { grid-column: auto; }
}
+167
View File
@@ -0,0 +1,167 @@
{% extends "base.html" %}
{% block title %}Admin {{ room.name }}{% endblock %}
{% block content %}
<div class="admin-head">
<h1 class="room-title">{{ room.name }}</h1>
{% if drawn %}
<span class="tag tag-gold">✨ Ausgelost</span>
{% else %}
<span class="tag">Wartet auf Auslosung</span>
{% endif %}
</div>
{% if message %}
<div class="alert {{ 'alert-error' if message_kind == 'error' else 'alert-ok' }}" role="alert">
{{ message }}
</div>
{% endif %}
{% if recovery_link %}
<section class="card card-highlight">
<h2>🔑 Wiederherstellungslink für {{ recovery_for }}</h2>
<p>Schicke diesen Link direkt an <strong>{{ recovery_for }}</strong>.
Er funktioniert <strong>ein einziges Mal</strong> und stellt dort das
Ergebnis wieder her:</p>
<div class="copy-row">
<input type="text" readonly value="{{ recovery_link }}" id="recovery-link">
<button type="button" class="btn btn-secondary" data-copy="#recovery-link">Kopieren</button>
</div>
<p class="hint">Ein eventuell älterer, unbenutzter Link für {{ recovery_for }}
wurde dadurch ersetzt.</p>
</section>
{% endif %}
<section class="card">
<h2>Einladung teilen</h2>
<p>Mit diesem Link melden sich die Teilnehmer an:</p>
<div class="copy-row">
<input type="text" readonly value="{{ share_link }}" id="share-link">
<button type="button" class="btn btn-secondary" data-copy="#share-link">Kopieren</button>
</div>
<p class="code-line">Raumcode: <span class="code">{{ room.code }}</span></p>
</section>
{% if not drawn %}
<section class="card">
<h2>Selbst mitwichteln 🎅</h2>
{% if admin_participant %}
<p>Du bist als <strong>{{ admin_participant.name }}</strong> mit dabei. ✅
Deine Teilnehmeransicht und später dein Ergebnis erreichst du über
den Einladungslink.</p>
{% else %}
<p>Auch der Admin braucht ein Los: Melde dich selbst als Teilnehmer an.</p>
<form method="post" action="/r/{{ room.code }}/admin/self-register" class="stack">
<label>
Dein Name
<input type="text" name="name" maxlength="50" required
autocomplete="name" placeholder="z. B. Nico">
</label>
<button type="submit" class="btn btn-secondary">Mich anmelden 🎁</button>
</form>
{% endif %}
</section>
{% endif %}
<section class="card">
<h2>Teilnehmer ({{ participants|length }})</h2>
{% if participants %}
<ul class="list">
{% for p in participants %}
<li class="list-item">
<span class="list-name">{{ p.name }}</span>
{% if not drawn %}
<form method="post" action="/r/{{ room.code }}/participants/{{ p.id }}/delete"
data-confirm="»{{ p.name }}« wirklich aus dem Raum entfernen?">
<button type="submit" class="btn btn-small btn-danger" aria-label="{{ p.name }} entfernen"></button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="hint">Noch niemand dabei teile den Einladungslink!</p>
{% endif %}
{% if drawn %}<p class="hint">Nach der Auslosung ist die Teilnehmerliste eingefroren.</p>{% endif %}
</section>
<section class="card">
<h2>Ausschlüsse</h2>
<p class="hint">Wer darf <em>wen nicht</em> ziehen? (z. B. Ehepartner gegenseitig
ausschließen das sind zwei Einträge)</p>
{% if not drawn %}
<form method="post" action="/r/{{ room.code }}/exclusions/add" class="exclusion-form">
<select name="from_id" required aria-label="Wer darf nicht ziehen?">
<option value="" disabled selected>Wer darf nicht…</option>
{% for p in participants %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
</select>
<select name="to_id" required aria-label="Wen nicht?">
<option value="" disabled selected>…wen ziehen?</option>
{% for p in participants %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
</select>
<button type="submit" class="btn btn-secondary" {% if participants|length < 2 %}disabled{% endif %}>
Ausschließen
</button>
</form>
{% endif %}
{% if exclusions %}
<ul class="list">
{% for e in exclusions %}
<li class="list-item">
<span class="list-name">{{ e.from_name }} ➜ darf nicht ➜ {{ e.to_name }}</span>
{% if not drawn %}
<form method="post" action="/r/{{ room.code }}/exclusions/{{ e.from_id }}/{{ e.to_id }}/delete">
<button type="submit" class="btn btn-small btn-danger" aria-label="Ausschluss entfernen"></button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% elif drawn %}
<p class="hint">Keine Ausschlüsse festgelegt.</p>
{% endif %}
{% if drawn %}<p class="hint">Nach der Auslosung sind die Ausschlüsse eingefroren.</p>{% endif %}
</section>
<section class="card">
<h2>Auslosung</h2>
{% if drawn %}
<div class="alert alert-ok">🎉 Es wurde ausgelost! Wer wen beschenkt, sehen nur die
Teilnehmer selbst auf ihrer Seite hier im Admin-Bereich bleibt das geheim.</div>
{% else %}
<p>Jede Person zieht genau eine andere Person niemand zieht sich selbst,
Ausschlüsse werden respektiert.</p>
<form method="post" action="/r/{{ room.code }}/draw"
data-confirm="Wirklich jetzt auslosen? Danach kann nichts mehr geändert werden.">
<button type="submit" class="btn btn-primary btn-big" {% if not can_draw %}disabled{% endif %}>
🎲 Jetzt auslosen
</button>
</form>
{% if not can_draw %}
<p class="hint">Mindestens {{ min_draw }} Teilnehmer nötig
(aktuell {{ participants|length }}).</p>
{% endif %}
{% endif %}
</section>
<section class="card">
<h2>Zugriff wiederherstellen 🔑</h2>
<p class="hint">Hat jemand das Cookie gelöscht oder ein neues Gerät?
Erstelle hier einen Einmal-Link, mit dem die Person ihr Ergebnis wiedersehen kann
auch nach der Auslosung. Du erfährst dabei weiterhin nicht, wer wen gezogen hat.</p>
<ul class="list">
{% for p in participants %}
<li class="list-item">
<span class="list-name">{{ p.name }}</span>
<form method="post" action="/r/{{ room.code }}/recovery/{{ p.id }}">
<button type="submit" class="btn btn-small btn-secondary">Link erstellen</button>
</form>
</li>
{% endfor %}
</ul>
{% if not participants %}<p class="hint">Noch keine Teilnehmer im Raum.</p>{% endif %}
</section>
<form method="post" action="/r/{{ room.code }}/admin/logout" class="logout-row">
<button type="submit" class="btn btn-ghost">Abmelden</button>
</form>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Admin-Login {{ room.name }}{% endblock %}
{% block content %}
<h1 class="room-title">{{ room.name }}</h1>
<section class="card">
<h2>Admin-Bereich 🔑</h2>
{% if error %}<div class="alert alert-error" role="alert">Das Passwort war leider falsch.</div>{% endif %}
<form method="post" action="/r/{{ room.code }}/admin/login" class="stack">
<label>
Admin-Passwort
<input type="password" name="password" required autofocus
autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary">Anmelden</button>
</form>
</section>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Wichtel-Werkstatt{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%85%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">🎅 <span>Wichtel-Werkstatt</span></a>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="site-footer">🎄 Fröhliches Wichteln! 🎄</footer>
<script src="/static/app.js" defer></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Wichtel-Werkstatt Raum erstellen oder beitreten{% endblock %}
{% block content %}
<section class="hero">
<h1>Wichtel-Werkstatt</h1>
<p>Wichteln ohne Zettel-Chaos: Raum erstellen, Mitmenschen einladen,
Ausschlüsse festlegen und fair auslosen. 🎁</p>
</section>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<section class="card">
<h2>Raum erstellen</h2>
<form method="post" action="/rooms" class="stack">
<label>
Name des Raums
<input type="text" name="room_name" maxlength="60" required
placeholder="z. B. Familie Müller 2026">
</label>
<label>
Admin-Passwort <small>(mind. 4 Zeichen)</small>
<input type="password" name="password" minlength="4" required
autocomplete="new-password" placeholder="Für die Raumverwaltung">
</label>
<button type="submit" class="btn btn-primary">Raum erstellen 🎄</button>
</form>
</section>
<div class="divider" aria-hidden="true">❄ oder ❄</div>
<section class="card">
<h2>Mit Code beitreten</h2>
<form method="post" action="/join" class="stack">
<label>
Raumcode
<input type="text" name="code" maxlength="6" required
autocapitalize="characters" spellcheck="false" placeholder="z. B. ABC123">
</label>
<button type="submit" class="btn btn-secondary">Zum Raum 🔔</button>
</form>
</section>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}{{ title }} Wichtel-Werkstatt{% endblock %}
{% block content %}
<section class="card card-message">
<h1>{{ title }}</h1>
<p>{{ text }}</p>
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</section>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ room.name }} Wichtel-Werkstatt{% endblock %}
{% block content %}
<h1 class="room-title">{{ room.name }}</h1>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if participant %}
{% if drawn %}
<section class="card card-result">
<p class="hello">Ho ho ho, {{ participant.name }}! 🎅</p>
<p class="result-label">Du beschenkst in diesem Jahr:</p>
<p class="result-name">🎁 {{ assignment.receiver_name if assignment else "…" }}</p>
<p class="hint">Verrate es niemandem die Überraschung soll echt sein! 🤫</p>
<p class="hint">Diese Seite merkt sich dein Ergebnis über deinen Browser.
Bleib also auf diesem Gerät oder frage den Admin nach einem neuen Link,
falls du den Zugriff verlierst.</p>
</section>
{% else %}
<section class="card">
<p class="hello">Hi {{ participant.name }}! 👋</p>
<p>Du bist als Wichtel angemeldet. ✅</p>
<p class="waiting">⏳ Jetzt heißt es warten, bis die Auslosung startet.
Schau einfach später nochmal hier vorbei dein Ergebnis erscheint
auf genau dieser Seite.</p>
<p class="hint">Wichtig: Diese Seite merkt sich dich über deinen Browser.
Bitte lösche das Cookie nicht und bleib auf diesem Gerät.</p>
</section>
{% endif %}
{% else %}
{% if drawn %}
<section class="card">
<p>Hier wurde bereits ausgelost eine Teilnahme ist leider nicht mehr möglich. 🎄</p>
</section>
{% else %}
<section class="card">
<h2>Mitmachen</h2>
<p>Gib deinen Namen ein, um bei <strong>{{ room.name }}</strong> mitzuwichteln.</p>
<form method="post" action="/r/{{ room.code }}/register" class="stack">
<label>
Dein Name
<input type="text" name="name" maxlength="50" required
autocomplete="name" placeholder="z. B. Anna">
</label>
<button type="submit" class="btn btn-primary">Mitwichteln 🎁</button>
</form>
<p class="hint">Nach der Anmeldung merkt sich diese Seite dich über deinen
Browser so siehst du später hier dein Ergebnis.</p>
</section>
{% endif %}
{% endif %}
{% endblock %}