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 %}