/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 %}
+
+{% 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 %}
+
+ {% endfor %}
+{% endif %}
+
+
+
+{% 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 %}
+
+{% 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 %}
+
+
+
+
+ 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.