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:
+68
-2
@@ -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('{} <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):
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
@@ -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>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url 'admin:gaehsnitz_user_changelist' %}">Users</a>
|
||||
› <a href="{% url 'admin:gaehsnitz_user_change' original.pk %}">{{ original }}</a>
|
||||
› 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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
·
|
||||
<a href="/admin/">Admin-Panel</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -56,7 +56,6 @@ class NewsView(GaehsnitzTemplateView):
|
||||
return context
|
||||
|
||||
|
||||
|
||||
class Archive2022View(GaehsnitzTemplateView):
|
||||
template_name = "gaehsnitz/archive-2022.html"
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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"))),
|
||||
]
|
||||
|
||||
@@ -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 `<id>/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.
|
||||
Reference in New Issue
Block a user