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
+6
View File
@@ -0,0 +1,6 @@
data/
__pycache__/
*.pyc
.git/
tests/
*.md
+3
View File
@@ -0,0 +1,3 @@
data/
__pycache__/
*.pyc
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x /app/entrypoint.sh \
&& useradd --create-home wichtel \
&& mkdir -p /data \
&& chown wichtel:wichtel /data
ENV WICHTELN_DATA_DIR=/data
EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"]
+74
View File
@@ -0,0 +1,74 @@
# Wichtel-Werkstatt 🎅
Wichteln ohne Zettel-Chaos: Ein Admin erstellt einen Raum, alle melden sich per
Link an, Ausschlüsse werden festgelegt (z. B. Ehepartner), dann wird fair
ausgelost. Jede Person sieht **nur ihr eigenes Ergebnis** der Admin sieht
keine Zuweisungen.
## So funktioniert's
1. **Raum erstellen:** Auf der Startseite Raumname + Admin-Passwort angeben.
Es gibt einen Einladungslink und einen 6-stelligen Code zum Weitergeben.
2. **Anmelden:** Alle Teilnehmer öffnen den Link und geben ihren Namen ein.
Der Browser merkt sich die Teilnahme über ein langlebiges Cookie.
Der Admin nimmt über „Selbst mitwichteln" im Panel direkt selbst teil.
3. **Ausschlüsse:** Der Admin legt einseitig fest: „A darf nicht B ziehen“.
(Ehepaare = zwei Einträge, je einer pro Richtung.)
4. **Auslosen:** Ab 3 Teilnehmern. Danach ist der Raum eingefroren
nichts kann mehr geändert werden.
5. **Ergebnis:** Jeder Teilnehmer sieht auf seiner Seite, wen er beschenkt.
Der Admin sieht nur, *dass* ausgelost wurde nicht, wer wen gezogen hat.
**Cookie verloren?** Der Admin kann im Panel jederzeit (auch nach der Auslosung)
einen Einmal-Wiederherstellungslink für eine Person erstellen. Damit bekommt
sie auf einem beliebigen Gerät ihr Ergebnis zurück. Der Link funktioniert
genau einmal und verrät dem Admin weiterhin nichts über die Zuweisungen.
## Technik
- Python/FastAPI, serverseitig gerenderte Templates (Jinja2), kein Build-Schritt
- SQLite-Datei in einem Volume dieses Verzeichnis zu sichern ist das komplette Backup
- Mobil-zuerst gestaltet, läuft ohne externe CDN/Font-Abhängigkeiten
## Lokal testen (Bazzite/podman)
```bash
podman build -t wichteln .
podman volume create wichtel-data # überlebt Container-Neustarts
podman run -d --name wichteln -p 127.0.0.1:8080:8000 \
-v wichtel-data:/data wichteln
```
Dann <http://localhost:8080> öffnen. Stoppen/aufräumen:
```bash
podman rm -f wichteln
# optional: podman volume rm wichtel-data
```
Alternativ mit Bind-Mount statt Volume (auf SELinux-Systemen wie Bazzite/Fedora
ist `:z` für das SELinux-Label nötig):
```bash
podman run -d --name wichteln -p 127.0.0.1:8080:8000 -v "$PWD/data:/data:z" localhost/wichteln
```
Die Compose-Datei funktioniert sowohl mit `docker compose` (Server) als auch
mit `podman-compose up -d` lokal.
## Auf dem Server (docker compose + nginx)
```bash
docker compose up -d --build
```
Der Container lauscht nur auf `127.0.0.1:8000`; davor gehört ein Reverse
Proxy siehe [`nginx.example.conf`](nginx.example.conf). **HTTPS wird dringend
empfohlen**, damit Ergebnis-Cookies nicht unverschlüsselt übertragen werden.
Die Daten liegen im `./data/`-Verzeichnis neben der Compose-Datei.
## Tests
```bash
python3 tests/test_draw.py # Auslosungslogik, ohne Abhängigkeiten
```
+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 %}
+13
View File
@@ -0,0 +1,13 @@
services:
wichteln:
build: .
container_name: wichteln
restart: unless-stopped
ports:
# Nur lokal binden öffentlich macht der nginx-Reverse-Proxy.
- "127.0.0.1:8000:8000"
volumes:
# SQLite-Datei liegt hier dieses Verzeichnis sichern = komplettes Backup.
# :z setzt das SELinux-Label für Container-Zugriff (auf Systemen ohne
# SELinux ein wirkungsloser No-op).
- ./data:/data:z
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -e
if [ "$(id -u)" = "0" ]; then
# Bind-Mounts (z. B. docker compose mit ./data) kommen als root an:
# Besitz anpassen und dann Rechte an den unprivilegierten Nutzer abgeben.
if chown wichtel:wichtel /data 2>/dev/null; then
exec setpriv --reuid=wichtel --regid=wichtel --clear-groups \
uvicorn app.main:app --host 0.0.0.0 --port 8000
fi
# chown nicht möglich (rootless podman): "root" im Container ist hier
# nur der normale Host-Nutzer direkt weiterlaufen.
fi
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
+28
View File
@@ -0,0 +1,28 @@
# Beispiel: Wichtel-Werkstatt hinter nginx als Reverse Proxy.
# Der Container lauscht auf 127.0.0.1:8000 (siehe docker-compose.yml).
#
# HTTPS wird dringend empfohlen ohne HTTPS werden die Cookies (und damit
# die Ergebnisse) unverschlüsselt übertragen. Zertifikate z. B. via certbot:
# certbot --nginx -d wichteln.example.org
server {
listen 80;
listen [::]:80;
server_name wichteln.example.org;
# Optional: HTTP komplett auf HTTPS umleiten
# return 301 https://$host$request_uri;
location / {
proxy_pass http://127.0.0.1:8000;
# Wichtig: Host und Protokoll weiterreichen, damit die App
# korrekte Links erzeugt und Cookies das Secure-Flag bekommen.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_http_version 1.1;
}
}
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
jinja2>=3.1
python-multipart>=0.0.9
Executable
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# End-to-End-Test gegen eine laufende Instanz.
# Aufruf: BASE=http://localhost:8080 bash tests/e2e.sh
set -euo pipefail
BASE="${BASE:-http://localhost:8080}"
JAR_DIR="$(mktemp -d)"
trap 'rm -rf "$JAR_DIR"' EXIT
PASS=0
FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1"; }
check() { # check <beschreibung> <bedingung-wahr?>
if [ "${2:-0}" = "1" ]; then ok "$1"; else fail "$1"; fi
}
redirect_of() { # POST mit Formulardaten, liefert Location-Header
curl -s -o /dev/null -w '%{redirect_url}' "${@}"
}
echo "== Raum erstellen =="
LOC=$(curl -s -c "$JAR_DIR/admin" -o /dev/null -w '%{redirect_url}' \
--data-urlencode "room_name=E2E Raum" \
--data-urlencode "password=strenggeheim" \
"$BASE/rooms")
CODE=$(printf '%s' "$LOC" | sed -E 's|.*/r/([A-Z2-9]{6})/admin|\1|')
check "Raum angelegt, Code=$CODE" "$([ ${#CODE} -eq 6 ] && echo 1 || echo 0)"
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
echo "$PANEL" | grep -q "Einladung teilen" && ok "Admin-Panel erreichbar" || fail "Admin-Panel erreichbar"
echo "== Falsches Admin-Passwort =="
curl -s -c "$JAR_DIR/fake" -o /dev/null --data-urlencode "password=falsch" "$BASE/r/$CODE/admin/login"
FAKE=$(curl -s -b "$JAR_DIR/fake" "$BASE/r/$CODE/admin")
echo "$FAKE" | grep -q "Admin-Bereich" && ok "Falsches Passwort bleibt im Login" || fail "Falsches Passwort bleibt im Login"
echo "== Teilnehmer registrieren =="
NAMES=(Anna Ben Clara David)
for n in "${NAMES[@]}"; do
LOC=$(curl -s -c "$JAR_DIR/$n" -o /dev/null -w '%{redirect_url}' \
--data-urlencode "name=$n" "$BASE/r/$CODE/register")
case "$LOC" in
*/r/"$CODE") ok "Registrierung $n" ;;
*) fail "Registrierung $n (Redirect: $LOC)" ;;
esac
done
LOC=$(curl -s -c "$JAR_DIR/dup" -o /dev/null -w '%{redirect_url}' \
--data-urlencode "name=anna" "$BASE/r/$CODE/register")
echo "$LOC" | grep -q "error=duplicate" && ok "Doppelter Name wird abgelehnt" || fail "Doppelter Name wird abgelehnt"
echo "== Admin nimmt selbst teil =="
SELF=$(curl -s -b "$JAR_DIR/admin" -c "$JAR_DIR/admin" --data-urlencode "name=Nico" "$BASE/r/$CODE/admin/self-register")
echo "$SELF" | grep -q "wichtelst als »Nico«" && ok "Admin als Teilnehmer angemeldet" || fail "Admin als Teilnehmer angemeldet"
WAIT=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE")
echo "$WAIT" | grep -q "Hi Nico" && ok "Admin sieht eigene Teilnehmeransicht" || fail "Admin sieht eigene Teilnehmeransicht"
SELF2=$(curl -s -b "$JAR_DIR/admin" --data-urlencode "name=Nico" "$BASE/r/$CODE/admin/self-register")
echo "$SELF2" | grep -q "bereits vergeben" && ok "Admin-Doppelname wird abgelehnt" || fail "Admin-Doppelname wird abgelehnt"
NAMES=(Anna Ben Clara David Nico)
# Nicos Teilnehmer-Cookie lebt im Admin-Jar für die Ergebnis-Prüfung kopieren
cp "$JAR_DIR/admin" "$JAR_DIR/Nico"
echo "== Ausschlüsse (Anna darf Ben nicht ziehen, Ben darf Anna nicht ziehen) =="
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
id_of() { printf '%s' "$PANEL" | grep -oE "value=\"[^\"]+\">$1<" | head -1 | sed -E 's/value="([^"]+)".*/\1/'; }
ANNA_ID=$(id_of Anna); BEN_ID=$(id_of Ben)
check "Teilnehmer-IDs gefunden" "$([ -n "$ANNA_ID" ] && [ -n "$BEN_ID" ] && echo 1 || echo 0)"
curl -s -b "$JAR_DIR/admin" -o /dev/null \
--data-urlencode "from_id=$ANNA_ID" --data-urlencode "to_id=$BEN_ID" "$BASE/r/$CODE/exclusions/add"
curl -s -b "$JAR_DIR/admin" -o /dev/null \
--data-urlencode "from_id=$BEN_ID" --data-urlencode "to_id=$ANNA_ID" "$BASE/r/$CODE/exclusions/add"
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
echo "$PANEL" | grep -q "Anna ➜ darf nicht ➜ Ben" && ok "Ausschluss sichtbar" || fail "Ausschluss sichtbar"
echo "== Auslosen =="
DRAW=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/draw")
echo "$DRAW" | grep -q "Auslosung ist abgeschlossen" && ok "Auslosung erfolgreich" || fail "Auslosung erfolgreich"
echo "== Ergebnisse der Teilnehmer =="
declare -A RECEIVER
for n in "${NAMES[@]}"; do
PAGE=$(curl -s -b "$JAR_DIR/$n" "$BASE/r/$CODE")
R=$(printf '%s' "$PAGE" | grep -oE 'result-name">🎁 [^<]+' | sed 's/result-name">🎁 //')
RECEIVER[$n]="$R"
if [ -n "$R" ] && [ "$R" != "$n" ]; then
ok "$n beschenkt $R"
else
fail "Ergebnis für $n (Wert: '$R')"
fi
done
SORTED=$(printf '%s\n' "${RECEIVER[@]}" | sort)
UNIQUE=$(printf '%s\n' "${RECEIVER[@]}" | sort -u)
check "Jede Person wird genau einmal beschenkt" "$([ "$SORTED" = "$UNIQUE" ] && echo 1 || echo 0)"
check "Ausschluss eingehalten: Anna ≠> Ben" "$([ "${RECEIVER[Anna]}" != "Ben" ] && echo 1 || echo 0)"
check "Ausschluss eingehalten: Ben ≠> Anna" "$([ "${RECEIVER[Ben]}" != "Anna" ] && echo 1 || echo 0)"
echo "== Admin sieht keine Zuweisungen =="
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
echo "$PANEL" | grep -q "Ausgelost" && ok "Admin sieht Auslosungs-Status" || fail "Admin sieht Auslosungs-Status"
LEAK=1
echo "$PANEL" | grep -q "Du beschenkst" && LEAK=0
for n in "${NAMES[@]}"; do
echo "$PANEL" | grep -qE "$n (beschenkt|zieht)" && LEAK=0
done
check "Keine Ergebnis-Details im Admin-Panel" "$LEAK"
echo "== Raum ist eingefroren =="
DEL=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/participants/$ANNA_ID/delete")
echo "$DEL" | grep -q "eingefroren\|können keine" && ok "Teilnehmer löschen nach Auslosung blockiert" || fail "Teilnehmer löschen nach Auslosung blockiert"
DRAW2=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/draw")
echo "$DRAW2" | grep -q "bereits ausgelost" && ok "Zweite Auslosung blockiert" || fail "Zweite Auslosung blockiert"
LATE=$(curl -s -o /dev/null -w '%{redirect_url}' -c "$JAR_DIR/late" --data-urlencode "name=Zuspät" "$BASE/r/$CODE/register")
PAGE=$(curl -s -b "$JAR_DIR/late" "$BASE/r/$CODE")
echo "$PAGE" | grep -q "nicht mehr möglich" && ok "Keine Registrierung nach Auslosung" || fail "Keine Registrierung nach Auslosung"
echo "== Recovery-Link (Cookie verloren) =="
RECOV=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/recovery/$ANNA_ID")
LINK=$(printf '%s' "$RECOV" | grep -oE 'value="[^"]+/recover/[^"]+"' | sed -E 's/^value="//; s/"$//')
check "Recovery-Link erzeugt" "$([ -n "$LINK" ] && echo 1 || echo 0)"
LOC=$(curl -s -c "$JAR_DIR/anna_neu" -o /dev/null -w '%{redirect_url}' "$LINK")
case "$LOC" in
http://*|https://*) TARGET="$LOC" ;;
*) TARGET="$BASE$LOC" ;;
esac
PAGE=$(curl -s -b "$JAR_DIR/anna_neu" "$TARGET")
if echo "$PAGE" | grep -q "Ho ho ho, Anna" && echo "$PAGE" | grep -q "🎁 ${RECEIVER[Anna]}"; then
ok "Anna sieht mit neuem Cookie wieder ihr Ergebnis (${RECEIVER[Anna]})"
else
fail "Anna sieht mit neuem Cookie wieder ihr Ergebnis"
fi
PAGE=$(curl -s -L "$LINK")
echo "$PAGE" | grep -q "bereits verwendet" && ok "Recovery-Link ist Einmal-Link" || fail "Recovery-Link ist Einmal-Link"
echo
echo "ERGEBNIS: $PASS ok, $FAIL fehlgeschlagen"
[ "$FAIL" -eq 0 ]
+85
View File
@@ -0,0 +1,85 @@
"""Tests für die Auslosungslogik.
Laufen ohne Abhängigkeiten: python tests/test_draw.py
(sind aber auch pytest-kompatibel)
"""
from __future__ import annotations
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.draw import draw_assignment
def check_mapping(mapping, ids, exclusions):
assert sorted(mapping.keys()) == sorted(ids), "jeder muss genau einmal geben"
assert sorted(mapping.values()) == sorted(ids), "jeder muss genau einmal gezogen werden"
for giver, receiver in mapping.items():
assert giver != receiver, "niemand darf sich selbst ziehen"
assert (giver, receiver) not in exclusions, "Ausschlüsse müssen gelten"
def test_basically():
ids = ["a", "b", "c", "d"]
mapping = draw_assignment(ids, set())
assert mapping is not None
check_mapping(mapping, ids, set())
def test_mindestgroesse():
assert draw_assignment([], set()) is None
assert draw_assignment(["a"], set()) is None
def test_ausschluesse_werden_respektiert():
ids = ["anna", "ben", "clara", "david"]
# Ehepaar: Anna und Ben dürfen sich nicht gegenseitig ziehen.
exclusions = {("anna", "ben"), ("ben", "anna")}
rng = random.Random(42)
for _ in range(300):
mapping = draw_assignment(ids, exclusions, rng=rng)
assert mapping is not None
check_mapping(mapping, ids, exclusions)
def test_unloesbar_zwei_personen_mit_ausschluss():
ids = ["a", "b"]
assert draw_assignment(ids, {("a", "b")}) is None
def test_unloesbar_kein_moeglicher_empfaenger():
# a darf weder b noch c ziehen → keine gültige Auslosung möglich.
ids = ["a", "b", "c"]
exclusions = {("a", "b"), ("a", "c")}
assert draw_assignment(ids, exclusions) is None
def test_zufall_variiert():
ids = [f"p{i}" for i in range(8)]
exclusions = {("p0", "p1"), ("p2", "p3")}
rng = random.Random(7)
results = set()
for _ in range(100):
mapping = draw_assignment(ids, exclusions, rng=rng)
assert mapping is not None
check_mapping(mapping, ids, exclusions)
results.add(tuple(sorted(mapping.items())))
assert len(results) > 1, "Auslosung soll zufällig variieren"
if __name__ == "__main__":
failures = 0
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
try:
fn()
print(f"{name}")
except AssertionError as e:
failures += 1
print(f"{name}: {e}")
print("ALLE TESTS OK" if failures == 0 else f"{failures} TEST(S) FEHLGESCHLAGEN")
sys.exit(1 if failures else 0)