Add suff drink booking tool with PIN auth

Self-service drink tab at /suff/ for festival attendees. Users log in
with username + 3-digit PIN stored in a separate User.pin field, so
staff/admin accounts can keep their strong password for /admin/ and
also use the drink tool with the same username. PINs for staff users
must be set from the admin panel via a dedicated "PIN setzen" view to
prevent account takeover by name collision.

Time-gated to the festival window (Thu–Sun in Berlin tz) with phases
before/booking/readonly/closed; in non-production mode the tool is
always in booking phase for local testing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:00:56 +02:00
parent 1d35f0b9b9
commit 47d46e8e6f
16 changed files with 565 additions and 3 deletions
+68 -2
View File
@@ -1,6 +1,12 @@
from django.contrib import admin
from django import forms
from django.contrib import admin, messages
from django.contrib.admin.utils import unquote
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from django.http import Http404, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from gaehsnitz.models import Donation, Payment, Drink, Consumption
@@ -14,6 +20,20 @@ class ConsumptionInline(admin.TabularInline):
extra = 0
class SetPinForm(forms.Form):
pin = forms.CharField(
label="Neue PIN (3 Ziffern)",
min_length=3,
max_length=3,
)
def clean_pin(self):
pin = self.cleaned_data["pin"]
if not pin.isdigit():
raise forms.ValidationError("PIN muss aus genau 3 Ziffern bestehen.")
return pin
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ("username", "consumed_drinks_price")
@@ -26,17 +46,63 @@ class CustomUserAdmin(UserAdmin):
"fields": (
"username",
"password",
"pin_status",
)
},
),
("BALANCE", {"fields": ("consumed_drinks_price",)}),
)
readonly_fields = ("consumed_drinks_price",)
readonly_fields = ("consumed_drinks_price", "pin_status")
inlines = (ConsumptionInline,)
def consumed_drinks_price(self, user: User):
return euro(user.consumed_drinks_price)
@admin.display(description="PIN")
def pin_status(self, user: User):
status = "gesetzt" if user.pin else "nicht gesetzt"
if user.pk is None:
return status
url = reverse("admin:gaehsnitz_user_set_pin", args=[user.pk])
return format_html('{} &nbsp; <a href="{}">PIN setzen</a>', status, url)
def get_urls(self):
urls = super().get_urls()
custom = [
path(
"<id>/pin/",
self.admin_site.admin_view(self.set_pin_view),
name="gaehsnitz_user_set_pin",
),
]
return custom + urls
def set_pin_view(self, request, id):
if not self.has_change_permission(request):
raise Http404
user = self.get_object(request, unquote(id))
if user is None:
raise Http404
if request.method == "POST":
form = SetPinForm(request.POST)
if form.is_valid():
user.set_pin(form.cleaned_data["pin"])
user.save(update_fields=["pin"])
messages.success(request, f"PIN für {user.username} gesetzt.")
return HttpResponseRedirect(reverse("admin:gaehsnitz_user_change", args=[user.pk]))
else:
form = SetPinForm()
context = {
**self.admin_site.each_context(request),
"title": f"PIN setzen für {user.username}",
"opts": self.model._meta,
"original": user,
"form": form,
}
return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context)
@admin.register(Donation)
class DonationAdmin(admin.ModelAdmin):
+25
View File
@@ -0,0 +1,25 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class PinBackend(ModelBackend):
"""Authenticate festival users by username + 3-digit PIN stored in `pin`.
Strong passwords stay on the User model for /admin/ via the default
ModelBackend. Staff can also set a PIN to use the drink tool with the
same username.
"""
def authenticate(self, request, username=None, pin=None, **kwargs):
if username is None or pin is None:
return None
User = get_user_model()
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return None
if not user.check_pin(pin):
return None
if not self.user_can_authenticate(user):
return None
return user
@@ -0,0 +1,22 @@
# Generated by Django 6.0.5 on 2026-05-14 09:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0002_remove_donation_from_user_remove_payment_from_user_and_more"),
]
operations = [
migrations.AddField(
model_name="consumption",
name="created_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="user",
name="pin",
field=models.CharField(blank=True, default="", max_length=128),
),
]
+12
View File
@@ -1,3 +1,4 @@
from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Sum, F
@@ -10,6 +11,16 @@ class PriceField(models.DecimalField):
class User(AbstractUser):
pin = models.CharField(max_length=128, blank=True, default="")
def set_pin(self, raw_pin):
self.pin = make_password(raw_pin)
def check_pin(self, raw_pin):
if not self.pin:
return False
return check_password(raw_pin, self.pin)
@property
def paid_drinks(self):
return self.consumption_list.filter(for_free=False)
@@ -132,3 +143,4 @@ class Consumption(models.Model):
amount = models.PositiveSmallIntegerField()
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
for_free = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True, null=True)
+207
View File
@@ -0,0 +1,207 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth import get_user_model
from django.db.models import F, Sum
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from django.views.decorators.http import require_http_methods
from gaehsnitz.models import Consumption, Drink
User = get_user_model()
BERLIN = ZoneInfo("Europe/Berlin")
BOOKING_START = datetime(2026, 6, 11, 0, 0, 0, tzinfo=BERLIN)
BOOKING_END = datetime(2026, 6, 14, 23, 59, 59, tzinfo=BERLIN)
READONLY_END = datetime(2026, 6, 21, 23, 59, 59, tzinfo=BERLIN)
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
def _now():
return timezone.now().astimezone(BERLIN)
def _phase():
if not settings.PRODUCTION:
return "booking"
now = _now()
if now < BOOKING_START:
return "before"
if now <= BOOKING_END:
return "booking"
if now <= READONLY_END:
return "readonly"
return "closed"
def _require_open(request):
"""Raise 404 once tool is fully closed. Return current phase otherwise."""
phase = _phase()
if phase == "closed":
raise Http404
return phase
def _current_festival_day():
weekday = _now().weekday()
return DAY_BY_WEEKDAY.get(weekday, 4)
def _normalize_name(raw):
return slugify(raw or "")[:150]
def _tab_context(user):
consumption = user.consumption_list.select_related("drink").order_by("-created_at", "-id")
paid = (
user.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(total=Sum("cost"))[
"total"
]
or 0
)
return {
"tab_user": user,
"consumption_list": consumption,
"total": paid,
}
@require_http_methods(["GET", "POST"])
def name_view(request):
phase = _require_open(request)
if request.user.is_authenticated:
return HttpResponseRedirect(reverse("suff:me"))
error = None
if request.method == "POST":
raw_name = request.POST.get("name", "")
username = _normalize_name(raw_name)
if not username:
error = "Bitte einen Namen eingeben."
else:
try:
existing = User.objects.get(username=username)
except User.DoesNotExist:
existing = None
if existing is None:
request.session["pending_username"] = username
request.session["pending_mode"] = "create"
return HttpResponseRedirect(reverse("suff:pin"))
if not existing.pin:
return render(
request,
"suff/no_pin.html",
{"phase": phase, "username": username},
)
request.session["pending_username"] = username
request.session["pending_mode"] = "login"
return HttpResponseRedirect(reverse("suff:pin"))
return render(request, "suff/name.html", {"phase": phase, "error": error})
@require_http_methods(["GET", "POST"])
def pin_view(request):
phase = _require_open(request)
username = request.session.get("pending_username")
mode = request.session.get("pending_mode")
if not username or mode not in ("create", "login"):
return HttpResponseRedirect(reverse("suff:name"))
error = None
if request.method == "POST":
pin = request.POST.get("pin", "")
if not (pin.isdigit() and len(pin) == 3):
error = "PIN muss aus genau 3 Ziffern bestehen."
elif mode == "create":
if User.objects.filter(username=username).exists():
error = "Name bereits vergeben."
return HttpResponseRedirect(reverse("suff:name"))
user = User(username=username)
user.set_pin(pin)
user.save()
authed = authenticate(request, username=username, pin=pin)
if authed is None:
error = "Login nach Anlegen fehlgeschlagen."
else:
login(request, authed, backend="gaehsnitz.auth_backends.PinBackend")
request.session.pop("pending_username", None)
request.session.pop("pending_mode", None)
return HttpResponseRedirect(reverse("suff:me"))
else:
authed = authenticate(request, username=username, pin=pin)
if authed is None:
error = "Falsche PIN."
else:
login(request, authed, backend="gaehsnitz.auth_backends.PinBackend")
request.session.pop("pending_username", None)
request.session.pop("pending_mode", None)
return HttpResponseRedirect(reverse("suff:me"))
return render(
request,
"suff/pin.html",
{
"phase": phase,
"error": error,
"mode": mode,
"username": username,
},
)
@login_required
@require_http_methods(["GET"])
def me_view(request):
phase = _require_open(request)
drinks = Drink.objects.order_by("name") if phase == "booking" else Drink.objects.none()
context = _tab_context(request.user)
context.update(
{
"phase": phase,
"drinks": drinks,
"current_day": _current_festival_day(),
}
)
return render(request, "suff/me.html", context)
@login_required
@require_http_methods(["POST"])
def book_view(request):
phase = _require_open(request)
if phase != "booking":
raise Http404
drink_id = request.POST.get("drink_id")
try:
drink = Drink.objects.get(pk=int(drink_id))
except (Drink.DoesNotExist, TypeError, ValueError):
return HttpResponseRedirect(reverse("suff:me"))
Consumption.objects.create(
user=request.user,
drink=drink,
amount=1,
day=_current_festival_day(),
for_free=False,
)
return HttpResponseRedirect(reverse("suff:me"))
@require_http_methods(["POST"])
def logout_view(request):
_require_open(request)
logout(request)
return HttpResponseRedirect(reverse("suff:name"))
+19
View File
@@ -0,0 +1,19 @@
from django.urls import path
from gaehsnitz.suff import (
book_view,
logout_view,
me_view,
name_view,
pin_view,
)
app_name = "suff"
urlpatterns = [
path("", name_view, name="name"),
path("pin/", pin_view, name="pin"),
path("me/", me_view, name="me"),
path("book/", book_view, name="book"),
path("logout/", logout_view, name="logout"),
]
@@ -0,0 +1,23 @@
{% extends "admin/base_site.html" %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Home</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:gaehsnitz_user_changelist' %}">Users</a>
&rsaquo; <a href="{% url 'admin:gaehsnitz_user_change' original.pk %}">{{ original }}</a>
&rsaquo; PIN setzen
</div>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<div>
{{ form.as_p }}
</div>
<div class="submit-row">
<input type="submit" value="PIN setzen" class="default" />
</div>
</form>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<title>Suff - Gähsnitz Open Air</title>
</head>
<body>
<h1>Suff</h1>
{% block content %}{% endblock %}
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Hallo {{ tab_user.username }}</h2>
{% if phase == "readonly" %}
<p><i>Festival vorbei. Buchungen geschlossen, nur noch Anzeige.</i></p>
{% endif %}
<h3>Deine Rechnung: {{ total|floatformat:2 }} €</h3>
<h3>Bisher gebucht</h3>
{% if consumption_list %}
<ul>
{% for c in consumption_list %}
<li>
{{ c.get_day_display }}{% if c.created_at %} {{ c.created_at|date:"H:i" }}{% endif %}:
{{ c.amount }}× {{ c.drink.name }}
{% if c.for_free %}
(gratis)
{% else %}
à {{ c.drink.sale_price_per_bottle|floatformat:2 }} €
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>Noch nichts gebucht.</p>
{% endif %}
{% if phase == "booking" %}
<h3>Neues Getränk buchen</h3>
<p>Heutiger Tag: {{ current_day }}</p>
{% for drink in drinks %}
<form method="post" action="{% url 'suff:book' %}" style="display:inline">
{% csrf_token %}
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
<button type="submit">
+1 {{ drink.name }} ({{ drink.sale_price_per_bottle|floatformat:2 }} €)
</button>
</form>
{% endfor %}
{% endif %}
<hr />
<form method="post" action="{% url 'suff:logout' %}">
{% csrf_token %}
<button type="submit">Logout</button>
</form>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Wer bist du?</h2>
<p>
Gib deinen Namen ein. Sonderzeichen, Leerzeichen und Großbuchstaben werden
entfernt (z.B. wird aus "Flo Hä!" → "flo-ha"). Merk dir den Namen so,
wie er hier nach dem Anlegen angezeigt wird.
</p>
{% if error %}<p><b>{{ error }}</b></p>{% endif %}
<form method="post" action="{% url 'suff:name' %}">
{% csrf_token %}
<label>
Name:
<input type="text" name="name" autofocus required />
</label>
<button type="submit">Weiter</button>
</form>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Keine PIN gesetzt</h2>
<p>
Für den Namen <b>{{ username }}</b> ist noch keine PIN hinterlegt.
</p>
<p>
Das ist ein Staff-Account. Aus Sicherheitsgründen kann die PIN für solche
Accounts nicht selbst gesetzt werden sonst könnte sich jeder mit dem
Namen eines Admins eine eigene PIN anlegen und damit hier einloggen.
</p>
<p>
Bitte einen Admin bitten, die PIN über das Admin-Panel zu setzen.
</p>
<p>
<a href="{% url 'suff:name' %}">Zurück</a>
&middot;
<a href="/admin/">Admin-Panel</a>
</p>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "suff/base.html" %}
{% block content %}
{% if mode == "create" %}
<h2>Neuer Account: {{ username }}</h2>
<p>Merk dir diesen Namen: <b>{{ username }}</b>. Du brauchst ihn beim nächsten Login.</p>
<p>Wähl eine 3-stellige PIN.</p>
{% else %}
<h2>Hallo {{ username }}</h2>
<p>Gib deine 3-stellige PIN ein.</p>
{% endif %}
{% if error %}<p><b>{{ error }}</b></p>{% endif %}
<form method="post" action="{% url 'suff:pin' %}">
{% csrf_token %}
<label>
PIN:
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}" maxlength="3"
minlength="3" required autofocus autocomplete="off" />
</label>
<button type="submit">
{% if mode == "create" %}Account anlegen{% else %}Login{% endif %}
</button>
</form>
<p>
Nicht du / keine PIN / vergessen?
<a href="{% url 'suff:name' %}">Zurück und neuen Namen wählen</a>
</p>
{% endblock %}
-1
View File
@@ -56,7 +56,6 @@ class NewsView(GaehsnitzTemplateView):
return context
class Archive2022View(GaehsnitzTemplateView):
template_name = "gaehsnitz/archive-2022.html"
+7
View File
@@ -101,3 +101,10 @@ USE_TZ = True
STATIC_URL = "/static/"
AUTH_USER_MODEL = "gaehsnitz.User"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"gaehsnitz.auth_backends.PinBackend",
]
LOGIN_URL = "/suff/"
+1
View File
@@ -3,5 +3,6 @@ from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("suff/", include(("gaehsnitz.suff_urls", "suff"))),
path("", include(("gaehsnitz.urls", "gaehsnitz"))),
]
+48
View File
@@ -0,0 +1,48 @@
# Suff drink booking tool
Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS, no CSS yet.
## Auth
- `User.pin` field (hashed CharField) stores 3-digit PIN, separate from `password`. Lets staff keep a strong password for `/admin/` and use the same username on `/suff/` with just a PIN.
- `PinBackend` (`gaehsnitz/auth_backends.py`) authenticates by `username` + `pin` via `user.check_pin()`. Default `ModelBackend` stays first in `AUTHENTICATION_BACKENDS` so `/admin/` keeps requiring the strong password.
- Staff PINs cannot be self-set on `/suff/`. If a name matches an existing user with no PIN, the user lands on `suff/no_pin.html` explaining that an admin must set the PIN via the admin panel — otherwise anyone could claim a staff name and lock out the real owner.
- Admin convenience: User change page shows PIN status ("gesetzt"/"nicht gesetzt") + "PIN setzen" link → custom admin view `<id>/pin/` with a 3-digit form, calls `user.set_pin()`.
## Name flow
- Username = `slugify(input)` (e.g. "Flo Hä!" → "flo-ha"). Slug shown back so user can memorize it.
- POST name → check existence:
- not found → set new PIN → create user → login
- found, has PIN → enter PIN → login
- found, no PIN → `no_pin.html` (ask admin)
## Booking
- `/suff/me/` shows: greeting (slug), running paid total, full consumption history with timestamps, drink buttons.
- Each drink = `+1` POST form. Server creates `Consumption(amount=1, day=current_weekday, for_free=False, created_at=auto)`.
- No undo, no delete, no edit. No special bartender role.
- History sorted newest-first, `created_at` shown as `Do 18:42` etc.
## Time gating (Berlin tz)
- Phases: `before` / `booking` / `readonly` / `closed`.
- Booking allowed Thu 2026-06-11 00:00 → Sun 2026-06-14 23:59.
- Read-only until Sun 2026-06-21 23:59.
- After: every `/suff/` URL returns 404.
- Local dev: `settings.PRODUCTION=False` forces `booking` phase always.
## Files
- `gaehsnitz/auth_backends.py``PinBackend`
- `gaehsnitz/suff.py` — views + phase logic
- `gaehsnitz/suff_urls.py` — routes
- `gaehsnitz/admin.py``SetPinForm` + `set_pin_view`
- `gaehsnitz/templates/suff/{base,name,pin,no_pin,me}.html`
- `gaehsnitz/templates/admin/gaehsnitz/user/set_pin.html`
- `gaehsnitz/migrations/0003_consumption_created_at_user_pin.py`
- Edits: `gaehsnitzproject/settings.py`, `gaehsnitzproject/urls.py`, `gaehsnitz/models.py`
## Next step
Mobile-first styling. Currently zero CSS. Big tap targets for drink buttons, sticky/large running total, dark-mode friendly for outdoor evening use. Visual feedback after booking, day-grouped history, friendlier empty state, bigger PIN input. Decide whether `/suff/` integrates into the GOA site header or stays a separate microsite.