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,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