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:
@@ -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"))
|
||||
Reference in New Issue
Block a user