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 import get_user_model
|
||||||
from django.contrib.auth.admin import UserAdmin
|
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 django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from gaehsnitz.models import Donation, Payment, Drink, Consumption
|
from gaehsnitz.models import Donation, Payment, Drink, Consumption
|
||||||
@@ -14,6 +20,20 @@ class ConsumptionInline(admin.TabularInline):
|
|||||||
extra = 0
|
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)
|
@admin.register(User)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
list_display = ("username", "consumed_drinks_price")
|
list_display = ("username", "consumed_drinks_price")
|
||||||
@@ -26,17 +46,63 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
"fields": (
|
"fields": (
|
||||||
"username",
|
"username",
|
||||||
"password",
|
"password",
|
||||||
|
"pin_status",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("BALANCE", {"fields": ("consumed_drinks_price",)}),
|
("BALANCE", {"fields": ("consumed_drinks_price",)}),
|
||||||
)
|
)
|
||||||
readonly_fields = ("consumed_drinks_price",)
|
readonly_fields = ("consumed_drinks_price", "pin_status")
|
||||||
inlines = (ConsumptionInline,)
|
inlines = (ConsumptionInline,)
|
||||||
|
|
||||||
def consumed_drinks_price(self, user: User):
|
def consumed_drinks_price(self, user: User):
|
||||||
return euro(user.consumed_drinks_price)
|
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)
|
@admin.register(Donation)
|
||||||
class DonationAdmin(admin.ModelAdmin):
|
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.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Sum, F
|
from django.db.models import Sum, F
|
||||||
@@ -10,6 +11,16 @@ class PriceField(models.DecimalField):
|
|||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
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
|
@property
|
||||||
def paid_drinks(self):
|
def paid_drinks(self):
|
||||||
return self.consumption_list.filter(for_free=False)
|
return self.consumption_list.filter(for_free=False)
|
||||||
@@ -132,3 +143,4 @@ class Consumption(models.Model):
|
|||||||
amount = models.PositiveSmallIntegerField()
|
amount = models.PositiveSmallIntegerField()
|
||||||
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
|
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
|
||||||
for_free = models.BooleanField(default=False)
|
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
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Archive2022View(GaehsnitzTemplateView):
|
class Archive2022View(GaehsnitzTemplateView):
|
||||||
template_name = "gaehsnitz/archive-2022.html"
|
template_name = "gaehsnitz/archive-2022.html"
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,10 @@ USE_TZ = True
|
|||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
AUTH_USER_MODEL = "gaehsnitz.User"
|
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 = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("suff/", include(("gaehsnitz.suff_urls", "suff"))),
|
||||||
path("", include(("gaehsnitz.urls", "gaehsnitz"))),
|
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