From 47d46e8e6f0d03028d63bdd79cf356bf5597447f Mon Sep 17 00:00:00 2001 From: Flo Ha Date: Thu, 14 May 2026 12:00:56 +0200 Subject: [PATCH] Add suff drink booking tool with PIN auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gaehsnitz/admin.py | 70 +++++- gaehsnitz/auth_backends.py | 25 +++ .../0003_consumption_created_at_user_pin.py | 22 ++ gaehsnitz/models.py | 12 + gaehsnitz/suff.py | 207 ++++++++++++++++++ gaehsnitz/suff_urls.py | 19 ++ .../admin/gaehsnitz/user/set_pin.html | 23 ++ gaehsnitz/templates/suff/base.html | 12 + gaehsnitz/templates/suff/me.html | 50 +++++ gaehsnitz/templates/suff/name.html | 19 ++ gaehsnitz/templates/suff/no_pin.html | 21 ++ gaehsnitz/templates/suff/pin.html | 31 +++ gaehsnitz/views.py | 1 - gaehsnitzproject/settings.py | 7 + gaehsnitzproject/urls.py | 1 + suff.md | 48 ++++ 16 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 gaehsnitz/auth_backends.py create mode 100644 gaehsnitz/migrations/0003_consumption_created_at_user_pin.py create mode 100644 gaehsnitz/suff.py create mode 100644 gaehsnitz/suff_urls.py create mode 100644 gaehsnitz/templates/admin/gaehsnitz/user/set_pin.html create mode 100644 gaehsnitz/templates/suff/base.html create mode 100644 gaehsnitz/templates/suff/me.html create mode 100644 gaehsnitz/templates/suff/name.html create mode 100644 gaehsnitz/templates/suff/no_pin.html create mode 100644 gaehsnitz/templates/suff/pin.html create mode 100644 suff.md diff --git a/gaehsnitz/admin.py b/gaehsnitz/admin.py index 37b2551..fc02e14 100644 --- a/gaehsnitz/admin.py +++ b/gaehsnitz/admin.py @@ -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('{}   PIN setzen', status, url) + + def get_urls(self): + urls = super().get_urls() + custom = [ + path( + "/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): diff --git a/gaehsnitz/auth_backends.py b/gaehsnitz/auth_backends.py new file mode 100644 index 0000000..9d7f085 --- /dev/null +++ b/gaehsnitz/auth_backends.py @@ -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 diff --git a/gaehsnitz/migrations/0003_consumption_created_at_user_pin.py b/gaehsnitz/migrations/0003_consumption_created_at_user_pin.py new file mode 100644 index 0000000..f190032 --- /dev/null +++ b/gaehsnitz/migrations/0003_consumption_created_at_user_pin.py @@ -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), + ), + ] diff --git a/gaehsnitz/models.py b/gaehsnitz/models.py index 63105d4..9bc01f5 100644 --- a/gaehsnitz/models.py +++ b/gaehsnitz/models.py @@ -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) diff --git a/gaehsnitz/suff.py b/gaehsnitz/suff.py new file mode 100644 index 0000000..000d86c --- /dev/null +++ b/gaehsnitz/suff.py @@ -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")) diff --git a/gaehsnitz/suff_urls.py b/gaehsnitz/suff_urls.py new file mode 100644 index 0000000..be509ff --- /dev/null +++ b/gaehsnitz/suff_urls.py @@ -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"), +] diff --git a/gaehsnitz/templates/admin/gaehsnitz/user/set_pin.html b/gaehsnitz/templates/admin/gaehsnitz/user/set_pin.html new file mode 100644 index 0000000..a5c81bd --- /dev/null +++ b/gaehsnitz/templates/admin/gaehsnitz/user/set_pin.html @@ -0,0 +1,23 @@ +{% extends "admin/base_site.html" %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+ {{ form.as_p }} +
+
+ +
+
+{% endblock %} diff --git a/gaehsnitz/templates/suff/base.html b/gaehsnitz/templates/suff/base.html new file mode 100644 index 0000000..7cb03a9 --- /dev/null +++ b/gaehsnitz/templates/suff/base.html @@ -0,0 +1,12 @@ + + + + + + Suff - Gähsnitz Open Air + + +

Suff

+ {% block content %}{% endblock %} + + diff --git a/gaehsnitz/templates/suff/me.html b/gaehsnitz/templates/suff/me.html new file mode 100644 index 0000000..625ba1d --- /dev/null +++ b/gaehsnitz/templates/suff/me.html @@ -0,0 +1,50 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Hallo {{ tab_user.username }}

+ +{% if phase == "readonly" %} +

Festival vorbei. Buchungen geschlossen, nur noch Anzeige.

+{% endif %} + +

Deine Rechnung: {{ total|floatformat:2 }} €

+ +

Bisher gebucht

+{% if consumption_list %} +
    + {% for c in consumption_list %} +
  • + {{ 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 %} +
  • + {% endfor %} +
+{% else %} +

Noch nichts gebucht.

+{% endif %} + +{% if phase == "booking" %} +

Neues Getränk buchen

+

Heutiger Tag: {{ current_day }}

+ {% for drink in drinks %} +
+ {% csrf_token %} + + +
+ {% endfor %} +{% endif %} + +
+
+ {% csrf_token %} + +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/name.html b/gaehsnitz/templates/suff/name.html new file mode 100644 index 0000000..9abca9a --- /dev/null +++ b/gaehsnitz/templates/suff/name.html @@ -0,0 +1,19 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Wer bist du?

+

+ 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. +

+{% if error %}

{{ error }}

{% endif %} +
+ {% csrf_token %} + + +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/no_pin.html b/gaehsnitz/templates/suff/no_pin.html new file mode 100644 index 0000000..7016642 --- /dev/null +++ b/gaehsnitz/templates/suff/no_pin.html @@ -0,0 +1,21 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Keine PIN gesetzt

+

+ Für den Namen {{ username }} ist noch keine PIN hinterlegt. +

+

+ 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. +

+

+ Bitte einen Admin bitten, die PIN über das Admin-Panel zu setzen. +

+

+ Zurück + · + Admin-Panel +

+{% endblock %} diff --git a/gaehsnitz/templates/suff/pin.html b/gaehsnitz/templates/suff/pin.html new file mode 100644 index 0000000..654e644 --- /dev/null +++ b/gaehsnitz/templates/suff/pin.html @@ -0,0 +1,31 @@ +{% extends "suff/base.html" %} + +{% block content %} +{% if mode == "create" %} +

Neuer Account: {{ username }}

+

Merk dir diesen Namen: {{ username }}. Du brauchst ihn beim nächsten Login.

+

Wähl eine 3-stellige PIN.

+{% else %} +

Hallo {{ username }}

+

Gib deine 3-stellige PIN ein.

+{% endif %} + +{% if error %}

{{ error }}

{% endif %} + +
+ {% csrf_token %} + + +
+ +

+ Nicht du / keine PIN / vergessen? + Zurück und neuen Namen wählen +

+{% endblock %} diff --git a/gaehsnitz/views.py b/gaehsnitz/views.py index 2129915..595d967 100644 --- a/gaehsnitz/views.py +++ b/gaehsnitz/views.py @@ -56,7 +56,6 @@ class NewsView(GaehsnitzTemplateView): return context - class Archive2022View(GaehsnitzTemplateView): template_name = "gaehsnitz/archive-2022.html" diff --git a/gaehsnitzproject/settings.py b/gaehsnitzproject/settings.py index d6b1e9b..073f30d 100644 --- a/gaehsnitzproject/settings.py +++ b/gaehsnitzproject/settings.py @@ -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/" diff --git a/gaehsnitzproject/urls.py b/gaehsnitzproject/urls.py index c55269c..fb20e5b 100644 --- a/gaehsnitzproject/urls.py +++ b/gaehsnitzproject/urls.py @@ -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"))), ] diff --git a/suff.md b/suff.md new file mode 100644 index 0000000..29bedc8 --- /dev/null +++ b/suff.md @@ -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 `/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.