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
+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"))