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