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:
@@ -0,0 +1,6 @@
|
|||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
tests/
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
+20
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.30
|
||||||
|
jinja2>=3.1
|
||||||
|
python-multipart>=0.0.9
|
||||||
Executable
+144
@@ -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 ]
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user