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,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 %}
|
||||
Reference in New Issue
Block a user