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"