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

- FastAPI + SQLite, serverseitig gerenderte Templates, mobil-zuerst
- Admin-Passwort, einseitige Ausschlüsse, eingefrorene Auslosung
- Teilnehmer sehen Ergebnis per Cookie; Einmal-Recovery-Links bei Verlust
- Docker/podman-tauglich (Entrypoint mit Privilegien-Drop, SELinux-:z)
- Unit-Tests für Auslosung, E2E-Testskript (30 Checks)
This commit is contained in:
2026-08-05 16:07:21 +02:00
commit d56be7b2c0
21 changed files with 1839 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
{% extends "base.html" %}
{% block title %}Admin {{ room.name }}{% endblock %}
{% block content %}
<div class="admin-head">
<h1 class="room-title">{{ room.name }}</h1>
{% if drawn %}
<span class="tag tag-gold">✨ Ausgelost</span>
{% else %}
<span class="tag">Wartet auf Auslosung</span>
{% endif %}
</div>
{% if message %}
<div class="alert {{ 'alert-error' if message_kind == 'error' else 'alert-ok' }}" role="alert">
{{ message }}
</div>
{% endif %}
{% if recovery_link %}
<section class="card card-highlight">
<h2>🔑 Wiederherstellungslink für {{ recovery_for }}</h2>
<p>Schicke diesen Link direkt an <strong>{{ recovery_for }}</strong>.
Er funktioniert <strong>ein einziges Mal</strong> und stellt dort das
Ergebnis wieder her:</p>
<div class="copy-row">
<input type="text" readonly value="{{ recovery_link }}" id="recovery-link">
<button type="button" class="btn btn-secondary" data-copy="#recovery-link">Kopieren</button>
</div>
<p class="hint">Ein eventuell älterer, unbenutzter Link für {{ recovery_for }}
wurde dadurch ersetzt.</p>
</section>
{% endif %}
<section class="card">
<h2>Einladung teilen</h2>
<p>Mit diesem Link melden sich die Teilnehmer an:</p>
<div class="copy-row">
<input type="text" readonly value="{{ share_link }}" id="share-link">
<button type="button" class="btn btn-secondary" data-copy="#share-link">Kopieren</button>
</div>
<p class="code-line">Raumcode: <span class="code">{{ room.code }}</span></p>
</section>
{% if not drawn %}
<section class="card">
<h2>Selbst mitwichteln 🎅</h2>
{% if admin_participant %}
<p>Du bist als <strong>{{ admin_participant.name }}</strong> mit dabei. ✅
Deine Teilnehmeransicht und später dein Ergebnis erreichst du über
den Einladungslink.</p>
{% else %}
<p>Auch der Admin braucht ein Los: Melde dich selbst als Teilnehmer an.</p>
<form method="post" action="/r/{{ room.code }}/admin/self-register" class="stack">
<label>
Dein Name
<input type="text" name="name" maxlength="50" required
autocomplete="name" placeholder="z. B. Nico">
</label>
<button type="submit" class="btn btn-secondary">Mich anmelden 🎁</button>
</form>
{% endif %}
</section>
{% endif %}
<section class="card">
<h2>Teilnehmer ({{ participants|length }})</h2>
{% if participants %}
<ul class="list">
{% for p in participants %}
<li class="list-item">
<span class="list-name">{{ p.name }}</span>
{% if not drawn %}
<form method="post" action="/r/{{ room.code }}/participants/{{ p.id }}/delete"
data-confirm="»{{ p.name }}« wirklich aus dem Raum entfernen?">
<button type="submit" class="btn btn-small btn-danger" aria-label="{{ p.name }} entfernen"></button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="hint">Noch niemand dabei teile den Einladungslink!</p>
{% endif %}
{% if drawn %}<p class="hint">Nach der Auslosung ist die Teilnehmerliste eingefroren.</p>{% endif %}
</section>
<section class="card">
<h2>Ausschlüsse</h2>
<p class="hint">Wer darf <em>wen nicht</em> ziehen? (z. B. Ehepartner gegenseitig
ausschließen das sind zwei Einträge)</p>
{% if not drawn %}
<form method="post" action="/r/{{ room.code }}/exclusions/add" class="exclusion-form">
<select name="from_id" required aria-label="Wer darf nicht ziehen?">
<option value="" disabled selected>Wer darf nicht…</option>
{% for p in participants %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
</select>
<select name="to_id" required aria-label="Wen nicht?">
<option value="" disabled selected>…wen ziehen?</option>
{% for p in participants %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
</select>
<button type="submit" class="btn btn-secondary" {% if participants|length < 2 %}disabled{% endif %}>
Ausschließen
</button>
</form>
{% endif %}
{% if exclusions %}
<ul class="list">
{% for e in exclusions %}
<li class="list-item">
<span class="list-name">{{ e.from_name }} ➜ darf nicht ➜ {{ e.to_name }}</span>
{% if not drawn %}
<form method="post" action="/r/{{ room.code }}/exclusions/{{ e.from_id }}/{{ e.to_id }}/delete">
<button type="submit" class="btn btn-small btn-danger" aria-label="Ausschluss entfernen"></button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% elif drawn %}
<p class="hint">Keine Ausschlüsse festgelegt.</p>
{% endif %}
{% if drawn %}<p class="hint">Nach der Auslosung sind die Ausschlüsse eingefroren.</p>{% endif %}
</section>
<section class="card">
<h2>Auslosung</h2>
{% if drawn %}
<div class="alert alert-ok">🎉 Es wurde ausgelost! Wer wen beschenkt, sehen nur die
Teilnehmer selbst auf ihrer Seite hier im Admin-Bereich bleibt das geheim.</div>
{% else %}
<p>Jede Person zieht genau eine andere Person niemand zieht sich selbst,
Ausschlüsse werden respektiert.</p>
<form method="post" action="/r/{{ room.code }}/draw"
data-confirm="Wirklich jetzt auslosen? Danach kann nichts mehr geändert werden.">
<button type="submit" class="btn btn-primary btn-big" {% if not can_draw %}disabled{% endif %}>
🎲 Jetzt auslosen
</button>
</form>
{% if not can_draw %}
<p class="hint">Mindestens {{ min_draw }} Teilnehmer nötig
(aktuell {{ participants|length }}).</p>
{% endif %}
{% endif %}
</section>
<section class="card">
<h2>Zugriff wiederherstellen 🔑</h2>
<p class="hint">Hat jemand das Cookie gelöscht oder ein neues Gerät?
Erstelle hier einen Einmal-Link, mit dem die Person ihr Ergebnis wiedersehen kann
auch nach der Auslosung. Du erfährst dabei weiterhin nicht, wer wen gezogen hat.</p>
<ul class="list">
{% for p in participants %}
<li class="list-item">
<span class="list-name">{{ p.name }}</span>
<form method="post" action="/r/{{ room.code }}/recovery/{{ p.id }}">
<button type="submit" class="btn btn-small btn-secondary">Link erstellen</button>
</form>
</li>
{% endfor %}
</ul>
{% if not participants %}<p class="hint">Noch keine Teilnehmer im Raum.</p>{% endif %}
</section>
<form method="post" action="/r/{{ room.code }}/admin/logout" class="logout-row">
<button type="submit" class="btn btn-ghost">Abmelden</button>
</form>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Admin-Login {{ room.name }}{% endblock %}
{% block content %}
<h1 class="room-title">{{ room.name }}</h1>
<section class="card">
<h2>Admin-Bereich 🔑</h2>
{% if error %}<div class="alert alert-error" role="alert">Das Passwort war leider falsch.</div>{% endif %}
<form method="post" action="/r/{{ room.code }}/admin/login" class="stack">
<label>
Admin-Passwort
<input type="password" name="password" required autofocus
autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary">Anmelden</button>
</form>
</section>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Wichtel-Werkstatt{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%85%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">🎅 <span>Wichtel-Werkstatt</span></a>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="site-footer">🎄 Fröhliches Wichteln! 🎄</footer>
<script src="/static/app.js" defer></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Wichtel-Werkstatt Raum erstellen oder beitreten{% endblock %}
{% block content %}
<section class="hero">
<h1>Wichtel-Werkstatt</h1>
<p>Wichteln ohne Zettel-Chaos: Raum erstellen, Mitmenschen einladen,
Ausschlüsse festlegen und fair auslosen. 🎁</p>
</section>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<section class="card">
<h2>Raum erstellen</h2>
<form method="post" action="/rooms" class="stack">
<label>
Name des Raums
<input type="text" name="room_name" maxlength="60" required
placeholder="z. B. Familie Müller 2026">
</label>
<label>
Admin-Passwort <small>(mind. 4 Zeichen)</small>
<input type="password" name="password" minlength="4" required
autocomplete="new-password" placeholder="Für die Raumverwaltung">
</label>
<button type="submit" class="btn btn-primary">Raum erstellen 🎄</button>
</form>
</section>
<div class="divider" aria-hidden="true">❄ oder ❄</div>
<section class="card">
<h2>Mit Code beitreten</h2>
<form method="post" action="/join" class="stack">
<label>
Raumcode
<input type="text" name="code" maxlength="6" required
autocapitalize="characters" spellcheck="false" placeholder="z. B. ABC123">
</label>
<button type="submit" class="btn btn-secondary">Zum Raum 🔔</button>
</form>
</section>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}{{ title }} Wichtel-Werkstatt{% endblock %}
{% block content %}
<section class="card card-message">
<h1>{{ title }}</h1>
<p>{{ text }}</p>
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</section>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ room.name }} Wichtel-Werkstatt{% endblock %}
{% block content %}
<h1 class="room-title">{{ room.name }}</h1>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if participant %}
{% if drawn %}
<section class="card card-result">
<p class="hello">Ho ho ho, {{ participant.name }}! 🎅</p>
<p class="result-label">Du beschenkst in diesem Jahr:</p>
<p class="result-name">🎁 {{ assignment.receiver_name if assignment else "…" }}</p>
<p class="hint">Verrate es niemandem die Überraschung soll echt sein! 🤫</p>
<p class="hint">Diese Seite merkt sich dein Ergebnis über deinen Browser.
Bleib also auf diesem Gerät oder frage den Admin nach einem neuen Link,
falls du den Zugriff verlierst.</p>
</section>
{% else %}
<section class="card">
<p class="hello">Hi {{ participant.name }}! 👋</p>
<p>Du bist als Wichtel angemeldet. ✅</p>
<p class="waiting">⏳ Jetzt heißt es warten, bis die Auslosung startet.
Schau einfach später nochmal hier vorbei dein Ergebnis erscheint
auf genau dieser Seite.</p>
<p class="hint">Wichtig: Diese Seite merkt sich dich über deinen Browser.
Bitte lösche das Cookie nicht und bleib auf diesem Gerät.</p>
</section>
{% endif %}
{% else %}
{% if drawn %}
<section class="card">
<p>Hier wurde bereits ausgelost eine Teilnahme ist leider nicht mehr möglich. 🎄</p>
</section>
{% else %}
<section class="card">
<h2>Mitmachen</h2>
<p>Gib deinen Namen ein, um bei <strong>{{ room.name }}</strong> mitzuwichteln.</p>
<form method="post" action="/r/{{ room.code }}/register" class="stack">
<label>
Dein Name
<input type="text" name="name" maxlength="50" required
autocomplete="name" placeholder="z. B. Anna">
</label>
<button type="submit" class="btn btn-primary">Mitwichteln 🎁</button>
</form>
<p class="hint">Nach der Anmeldung merkt sich diese Seite dich über deinen
Browser so siehst du später hier dein Ergebnis.</p>
</section>
{% endif %}
{% endif %}
{% endblock %}