From 50fc32c577d8eb965d0fabed2929e983df8d98b6 Mon Sep 17 00:00:00 2001 From: Flo Ha Date: Tue, 26 May 2026 18:05:20 +0200 Subject: [PATCH] Add booking deletion, crew views, anonymous walk-in, and account claim - Users can delete their own bookings (confirmation page) - Crew page tree (/suff/staff/): book/pay/delete for any user, register new users, set/reset PINs - Anonymous walk-in user "anonym": bookings auto-create matching cash payment so balance stays at 0 - Self-signup: unknown name creates account (PIN required); known name without PIN and no activity allows claim (PIN required); known name without PIN but with activity blocks and points to bar crew - Crew-only PIN set/reset; no random PINs, PINs never displayed - Cyan .staff-target highlight on all crew pages - Updated suff.md with current feature state and open ideas Co-Authored-By: Claude Opus 4.7 --- gaehsnitz/migrations/0009_anonymous_user.py | 27 ++ gaehsnitz/static/suff/style.css | 57 ++- gaehsnitz/suff.py | 357 ++++++++++++++++-- gaehsnitz/suff_urls.py | 20 + gaehsnitz/templates/suff/confirm_delete.html | 19 + gaehsnitz/templates/suff/me.html | 4 + gaehsnitz/templates/suff/no_pin.html | 14 +- gaehsnitz/templates/suff/pin.html | 5 +- .../templates/suff/staff_confirm_delete.html | 24 ++ gaehsnitz/templates/suff/staff_index.html | 41 ++ gaehsnitz/templates/suff/staff_pay.html | 63 ++++ gaehsnitz/templates/suff/staff_pin_reset.html | 24 ++ gaehsnitz/templates/suff/staff_register.html | 29 ++ gaehsnitz/templates/suff/staff_user.html | 95 +++++ suff.md | 120 ++++-- 15 files changed, 828 insertions(+), 71 deletions(-) create mode 100644 gaehsnitz/migrations/0009_anonymous_user.py create mode 100644 gaehsnitz/templates/suff/confirm_delete.html create mode 100644 gaehsnitz/templates/suff/staff_confirm_delete.html create mode 100644 gaehsnitz/templates/suff/staff_index.html create mode 100644 gaehsnitz/templates/suff/staff_pay.html create mode 100644 gaehsnitz/templates/suff/staff_pin_reset.html create mode 100644 gaehsnitz/templates/suff/staff_register.html create mode 100644 gaehsnitz/templates/suff/staff_user.html diff --git a/gaehsnitz/migrations/0009_anonymous_user.py b/gaehsnitz/migrations/0009_anonymous_user.py new file mode 100644 index 0000000..990d62d --- /dev/null +++ b/gaehsnitz/migrations/0009_anonymous_user.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +ANONYMOUS_USERNAME = "anonym" + + +def create_anonymous(apps, schema_editor): + User = apps.get_model("gaehsnitz", "User") + User.objects.get_or_create( + username=ANONYMOUS_USERNAME, + defaults={"pin": "", "is_staff": False, "is_superuser": False}, + ) + + +def delete_anonymous(apps, schema_editor): + User = apps.get_model("gaehsnitz", "User") + User.objects.filter(username=ANONYMOUS_USERNAME).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("gaehsnitz", "0008_alter_drink_category"), + ] + + operations = [ + migrations.RunPython(create_anonymous, delete_anonymous), + ] diff --git a/gaehsnitz/static/suff/style.css b/gaehsnitz/static/suff/style.css index c820d63..79dd841 100644 --- a/gaehsnitz/static/suff/style.css +++ b/gaehsnitz/static/suff/style.css @@ -286,7 +286,8 @@ section { .history li { display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: auto 1fr auto auto; + align-items: center; gap: 10px; padding: 10px 12px; background-color: rgba(80, 40, 10, 0.2); @@ -294,6 +295,60 @@ section { font-size: 0.95rem; } +.hist-delete { + color: #885522; + font-size: 1.1rem; + text-decoration: none; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; +} + +.hist-delete:hover, .hist-delete:focus { + color: #FFCC77; + background-color: rgba(80, 40, 10, 0.4); +} + +.btn-danger { + background-color: #cc4422; + color: #ffffff; +} + +.btn-danger:hover, .btn-danger:focus { + background-color: #ee6644; + color: #ffffff; +} + +.confirm-actions { + display: flex; + flex-direction: row; + gap: 12px; + margin-top: 16px; +} + +.staff-target { + color: #66ddee; + background-color: rgba(102, 221, 238, 0.12); + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba(102, 221, 238, 0.4); +} + +.confirm-actions > button, +.confirm-actions > a { + flex: 1; + width: auto; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 14px 20px; + border-radius: 6px; + font-size: 1.1rem; + font-weight: bold; + min-height: 52px; +} + .history li:nth-child(odd) { background-color: rgba(80, 40, 10, 0.35); } diff --git a/gaehsnitz/suff.py b/gaehsnitz/suff.py index e1dc5c3..b13e037 100644 --- a/gaehsnitz/suff.py +++ b/gaehsnitz/suff.py @@ -18,6 +18,8 @@ from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, User = get_user_model() +ANONYMOUS_USERNAME = "anonym" + BERLIN = ZoneInfo("Europe/Berlin") # TEST WINDOW (2026-05-15 – 2026-05-31): enabled early for pre-festival testing. # Original festival phase: BOOKING_START = 2026-06-11, BOOKING_END = 2026-06-14. @@ -60,6 +62,44 @@ def _normalize_name(raw): return slugify(raw or "")[:150] +def _user_has_activity(user): + return Consumption.objects.filter(user=user).exists() or UserPayment.objects.filter(user=user).exists() + + +def _is_anonymous(user): + return user.username == ANONYMOUS_USERNAME + + +def _staff_required(view): + def wrapped(request, *args, **kwargs): + if not request.user.is_authenticated: + return HttpResponseRedirect(reverse("suff:name")) + if not request.user.is_staff: + raise Http404 + return view(request, *args, **kwargs) + + return wrapped + + +def _drink_grid_qs(): + return ( + Drink.objects.filter(year=current_year()) + .annotate( + category_order=Case( + When(category="beer", then=Value(0)), + When(category="alc_free_beer", then=Value(1)), + When(category="radler", then=Value(2)), + When(category="alc_free_radler", then=Value(3)), + When(category="soft", then=Value(4)), + When(category="water", then=Value(5)), + default=Value(99), + output_field=IntegerField(), + ) + ) + .order_by("category_order", "name") + ) + + def _tab_context(user): year = current_year() consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id") @@ -108,11 +148,15 @@ def name_view(request): return HttpResponseRedirect(reverse("suff:pin")) if not existing.pin: - return render( - request, - "suff/no_pin.html", - {"phase": phase, "username": username}, - ) + if _user_has_activity(existing): + return render( + request, + "suff/no_pin.html", + {"phase": phase, "username": username}, + ) + request.session["pending_username"] = username + request.session["pending_mode"] = "claim" + return HttpResponseRedirect(reverse("suff:pin")) request.session["pending_username"] = username request.session["pending_mode"] = "login" @@ -128,7 +172,7 @@ def pin_view(request): return redirect username = request.session.get("pending_username") mode = request.session.get("pending_mode") - if not username or mode not in ("create", "login"): + if not username or mode not in ("create", "login", "claim"): return HttpResponseRedirect(reverse("suff:name")) error = None @@ -151,6 +195,23 @@ def pin_view(request): request.session.pop("pending_username", None) request.session.pop("pending_mode", None) return HttpResponseRedirect(reverse("suff:me")) + elif mode == "claim": + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + return HttpResponseRedirect(reverse("suff:name")) + if user.pin or _user_has_activity(user): + return HttpResponseRedirect(reverse("suff:name")) + 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: @@ -179,24 +240,7 @@ def me_view(request): redirect, phase = _require_open(request) if redirect: return redirect - drinks = ( - Drink.objects.filter(year=current_year()) - .annotate( - category_order=Case( - When(category="beer", then=Value(0)), - When(category="alc_free_beer", then=Value(1)), - When(category="radler", then=Value(2)), - When(category="alc_free_radler", then=Value(3)), - When(category="soft", then=Value(4)), - When(category="water", then=Value(5)), - default=Value(99), - output_field=IntegerField(), - ) - ) - .order_by("category_order", "name") - if phase == "booking" - else Drink.objects.none() - ) + drinks = _drink_grid_qs() if phase == "booking" else Drink.objects.none() booked_drink = None booked_id = request.GET.get("booked") if booked_id: @@ -226,6 +270,9 @@ def book_view(request): if phase != "booking": raise Http404 + if _is_anonymous(request.user): + raise Http404 + drink_id = request.POST.get("drink_id") try: drink = Drink.objects.get(pk=int(drink_id)) @@ -242,6 +289,31 @@ def book_view(request): return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}") +@login_required +@require_http_methods(["GET", "POST"]) +def delete_consumption_view(request, consumption_id): + redirect, phase = _require_open(request) + if redirect: + return redirect + if phase != "booking": + raise Http404 + + try: + consumption = Consumption.objects.select_related("drink").get(pk=consumption_id, user=request.user) + except Consumption.DoesNotExist: + raise Http404 + + if request.method == "POST": + consumption.delete() + return HttpResponseRedirect(reverse("suff:me")) + + return render( + request, + "suff/confirm_delete.html", + {"phase": phase, "consumption": consumption}, + ) + + @login_required @require_http_methods(["GET", "POST"]) def pay_view(request): @@ -279,6 +351,243 @@ def pay_view(request): return render(request, "suff/pay.html", context) +def _get_staff_target(username): + try: + return User.objects.get(username=username) + except User.DoesNotExist: + raise Http404 + + +@_staff_required +@require_http_methods(["GET", "POST"]) +def staff_register_view(request): + redirect, phase = _require_open(request) + if redirect: + return redirect + + error = None + prefill_name = "" + prefill_pin = "" + if request.method == "POST": + raw_name = request.POST.get("name", "") + username = _normalize_name(raw_name) + pin = (request.POST.get("pin") or "").strip() + prefill_name = raw_name + prefill_pin = pin + + if not username: + error = "Bitte einen Namen eingeben." + elif User.objects.filter(username=username).exists(): + error = "Name bereits vergeben." + elif pin and not (pin.isdigit() and len(pin) == 3): + error = "PIN muss aus genau 3 Ziffern bestehen (oder leer lassen)." + else: + user = User(username=username) + if pin: + user.set_pin(pin) + user.save() + return HttpResponseRedirect(reverse("suff:staff_user", args=[username])) + + return render( + request, + "suff/staff_register.html", + { + "phase": phase, + "error": error, + "prefill_name": prefill_name, + "prefill_pin": prefill_pin, + }, + ) + + +@_staff_required +@require_http_methods(["GET", "POST"]) +def staff_pin_reset_view(request, username): + redirect, phase = _require_open(request) + if redirect: + return redirect + + target = _get_staff_target(username) + if _is_anonymous(target): + raise Http404 + + error = None + if request.method == "POST": + pin = (request.POST.get("pin") or "").strip() + if not (pin.isdigit() and len(pin) == 3): + error = "PIN muss aus genau 3 Ziffern bestehen." + else: + target.set_pin(pin) + target.save() + return HttpResponseRedirect(reverse("suff:staff_user", args=[username])) + + return render( + request, + "suff/staff_pin_reset.html", + {"phase": phase, "tab_user": target, "error": error}, + ) + + +@_staff_required +@require_http_methods(["GET"]) +def staff_index_view(request): + redirect, phase = _require_open(request) + if redirect: + return redirect + + anon = User.objects.filter(username=ANONYMOUS_USERNAME).first() + others = User.objects.exclude(username=ANONYMOUS_USERNAME).order_by("username") + return render( + request, + "suff/staff_index.html", + {"phase": phase, "anonymous": anon, "users": others}, + ) + + +@_staff_required +@require_http_methods(["GET"]) +def staff_user_view(request, username): + redirect, phase = _require_open(request) + if redirect: + return redirect + + target = _get_staff_target(username) + drinks = _drink_grid_qs() if phase == "booking" else Drink.objects.none() + booked_drink = None + booked_id = request.GET.get("booked") + if booked_id: + try: + booked_drink = Drink.objects.get(pk=int(booked_id)) + except (Drink.DoesNotExist, TypeError, ValueError): + booked_drink = None + + context = _tab_context(target) + context.update( + { + "phase": phase, + "drinks": drinks, + "current_day": _current_festival_day(), + "booked_drink": booked_drink, + "paid_toast": request.GET.get("paid") == "1", + "is_anonymous_target": _is_anonymous(target), + } + ) + return render(request, "suff/staff_user.html", context) + + +@_staff_required +@require_http_methods(["POST"]) +def staff_book_view(request, username): + redirect, phase = _require_open(request) + if redirect: + return redirect + if phase != "booking": + raise Http404 + + target = _get_staff_target(username) + 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:staff_user", args=[username])) + + Consumption.objects.create( + user=target, + drink=drink, + amount=1, + day=_current_festival_day(), + for_free=False, + ) + if _is_anonymous(target): + UserPayment.objects.create( + user=target, + amount=drink.sale_price_per_bottle, + method=UserPayment.Method.cash, + note=f"Auto: {drink.name}", + ) + return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}") + + +@_staff_required +@require_http_methods(["GET", "POST"]) +def staff_pay_view(request, username): + redirect, phase = _require_open(request) + if redirect: + return redirect + + target = _get_staff_target(username) + if _is_anonymous(target): + raise Http404 + + error = None + if request.method == "POST": + raw_amount = (request.POST.get("amount") or "").replace(",", ".").strip() + method = request.POST.get("method") or "" + note = (request.POST.get("note") or "").strip()[:64] + + valid_methods = {m for m, _ in UserPayment.Method.choices} + try: + amount = float(raw_amount) + except ValueError: + amount = 0.0 + + if method not in valid_methods: + error = "Bitte eine Zahlungsmethode wählen." + elif amount <= 0: + error = "Bitte einen Betrag größer 0 eingeben." + else: + UserPayment.objects.create( + user=target, + amount=amount, + method=method, + note=note, + ) + return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?paid=1") + + context = _tab_context(target) + context.update({"phase": phase, "error": error, "is_anonymous_target": False}) + return render(request, "suff/staff_pay.html", context) + + +@_staff_required +@require_http_methods(["GET", "POST"]) +def staff_delete_consumption_view(request, username, consumption_id): + redirect, phase = _require_open(request) + if redirect: + return redirect + if phase != "booking": + raise Http404 + + target = _get_staff_target(username) + try: + consumption = Consumption.objects.select_related("drink").get(pk=consumption_id, user=target) + except Consumption.DoesNotExist: + raise Http404 + + if request.method == "POST": + if _is_anonymous(target): + matching = ( + UserPayment.objects.filter( + user=target, + method=UserPayment.Method.cash, + amount=consumption.drink.sale_price_per_bottle, + note=f"Auto: {consumption.drink.name}", + ) + .order_by("-created_at") + .first() + ) + if matching: + matching.delete() + consumption.delete() + return HttpResponseRedirect(reverse("suff:staff_user", args=[username])) + + return render( + request, + "suff/staff_confirm_delete.html", + {"phase": phase, "consumption": consumption, "tab_user": target}, + ) + + DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"} diff --git a/gaehsnitz/suff_urls.py b/gaehsnitz/suff_urls.py index a80115a..f228c03 100644 --- a/gaehsnitz/suff_urls.py +++ b/gaehsnitz/suff_urls.py @@ -4,11 +4,19 @@ from gaehsnitz.suff import ( book_view, closed_view, dashboard_view, + delete_consumption_view, logout_view, me_view, name_view, pay_view, pin_view, + staff_book_view, + staff_delete_consumption_view, + staff_index_view, + staff_pay_view, + staff_pin_reset_view, + staff_register_view, + staff_user_view, ) app_name = "suff" @@ -19,7 +27,19 @@ urlpatterns = [ path("pin/", pin_view, name="pin"), path("me/", me_view, name="me"), path("book/", book_view, name="book"), + path("book//delete/", delete_consumption_view, name="delete_consumption"), path("pay/", pay_view, name="pay"), path("dashboard/", dashboard_view, name="dashboard"), + path("staff/", staff_index_view, name="staff_index"), + path("staff/new/", staff_register_view, name="staff_register"), + path("staff/u//", staff_user_view, name="staff_user"), + path("staff/u//pin/", staff_pin_reset_view, name="staff_pin_reset"), + path("staff/u//book/", staff_book_view, name="staff_book"), + path("staff/u//pay/", staff_pay_view, name="staff_pay"), + path( + "staff/u//book//delete/", + staff_delete_consumption_view, + name="staff_delete_consumption", + ), path("logout/", logout_view, name="logout"), ] diff --git a/gaehsnitz/templates/suff/confirm_delete.html b/gaehsnitz/templates/suff/confirm_delete.html new file mode 100644 index 0000000..bd1d73c --- /dev/null +++ b/gaehsnitz/templates/suff/confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Buchung löschen?

+ +

+ Willst du wirklich + {{ consumption.drink.name }} + ({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}) + von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %} + löschen? +

+ +
+ {% csrf_token %} + + Nein, zurück +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/me.html b/gaehsnitz/templates/suff/me.html index b50d46a..9ffb182 100644 --- a/gaehsnitz/templates/suff/me.html +++ b/gaehsnitz/templates/suff/me.html @@ -64,6 +64,9 @@ {% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %} + {% if phase == "booking" %} + 🗑 + {% endif %} {% endfor %} @@ -80,6 +83,7 @@ {% if request.user.is_staff %} {% endif %} diff --git a/gaehsnitz/templates/suff/no_pin.html b/gaehsnitz/templates/suff/no_pin.html index 4dc171e..49e372f 100644 --- a/gaehsnitz/templates/suff/no_pin.html +++ b/gaehsnitz/templates/suff/no_pin.html @@ -1,20 +1,16 @@ {% extends "suff/base.html" %} {% block content %} -

Keine PIN gesetzt

+

PIN benötigt

- Für den Namen {{ username }} ist noch keine PIN hinterlegt. + Für den Namen {{ username }} ist keine PIN gesetzt, aber + der Account hat schon Buchungen oder Zahlungen.

- 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. + Aus Sicherheit kannst du diesen Account nicht selbst übernehmen. + Bitte jemanden an der Bar, dir eine neue PIN zu setzen.

{% endblock %} diff --git a/gaehsnitz/templates/suff/pin.html b/gaehsnitz/templates/suff/pin.html index 654e644..f69768d 100644 --- a/gaehsnitz/templates/suff/pin.html +++ b/gaehsnitz/templates/suff/pin.html @@ -5,6 +5,9 @@

Neuer Account: {{ username }}

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

Wähl eine 3-stellige PIN.

+{% elif mode == "claim" %} +

Account übernehmen: {{ username }}

+

Dieser Account hat noch keine PIN und keine Buchungen. Setz jetzt eine 3-stellige PIN, um ihn zu übernehmen.

{% else %}

Hallo {{ username }}

Gib deine 3-stellige PIN ein.

@@ -20,7 +23,7 @@ minlength="3" required autofocus autocomplete="off" /> diff --git a/gaehsnitz/templates/suff/staff_confirm_delete.html b/gaehsnitz/templates/suff/staff_confirm_delete.html new file mode 100644 index 0000000..9dcf673 --- /dev/null +++ b/gaehsnitz/templates/suff/staff_confirm_delete.html @@ -0,0 +1,24 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Crew-Ansicht

+

Buchung von {{ tab_user.username }} löschen?

+ +

+ Willst du wirklich + {{ consumption.drink.name }} + ({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}) + von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %} + löschen? +

+ +{% if tab_user.username == "anonym" %} +

Die zugehörige Bar-Zahlung wird ebenfalls entfernt.

+{% endif %} + +
+ {% csrf_token %} + + Nein, zurück +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/staff_index.html b/gaehsnitz/templates/suff/staff_index.html new file mode 100644 index 0000000..139e42a --- /dev/null +++ b/gaehsnitz/templates/suff/staff_index.html @@ -0,0 +1,41 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Crew-Ansicht

+

Benutzer wählen

+ +

Buchung oder Zahlung im Auftrag eines Benutzers eintragen.

+ + + +{% if anonymous %} + +{% endif %} + +
+

Registrierte Benutzer

+ {% if users %} + + {% else %} +

Keine Benutzer.

+ {% endif %} +
+ +
+ Zurück +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/staff_pay.html b/gaehsnitz/templates/suff/staff_pay.html new file mode 100644 index 0000000..85db9f9 --- /dev/null +++ b/gaehsnitz/templates/suff/staff_pay.html @@ -0,0 +1,63 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Crew-Ansicht

+

Zahlung für {{ tab_user.username }}

+ +
+ Rechnung + {{ total|floatformat:2 }} € + {% if paid %} + Bezahlt + {{ paid|floatformat:2 }} € + {% endif %} + Offen + {{ open_balance|floatformat:2 }} € +
+ +{% if error %} +

{{ error }}

+{% endif %} + +
+ {% csrf_token %} + + + + +
+ +{% if user_payments %} +
+

Bisherige Zahlungen

+
    + {% for p in user_payments %} +
  • + {{ p.created_at|date:"d.m. H:i" }} + {{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %} + {{ p.amount|floatformat:2 }} € +
  • + {% endfor %} +
+
+{% endif %} + +
+ Zurück +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/staff_pin_reset.html b/gaehsnitz/templates/suff/staff_pin_reset.html new file mode 100644 index 0000000..f6c5f75 --- /dev/null +++ b/gaehsnitz/templates/suff/staff_pin_reset.html @@ -0,0 +1,24 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Crew-Ansicht

+

PIN setzen für {{ tab_user.username }}

+ +

Neue 3-stellige PIN eingeben. Eine bestehende PIN wird überschrieben.

+ +{% if error %}

{{ error }}

{% endif %} + +
+ {% csrf_token %} + + +
+ +
+ Zurück +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/staff_register.html b/gaehsnitz/templates/suff/staff_register.html new file mode 100644 index 0000000..fd0ecbc --- /dev/null +++ b/gaehsnitz/templates/suff/staff_register.html @@ -0,0 +1,29 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Crew-Ansicht

+

Neuen Benutzer anlegen

+ +

Name eingeben. PIN ist optional — kann später bei Bedarf gesetzt werden.

+ +{% if error %}

{{ error }}

{% endif %} + +
+ {% csrf_token %} + + + +
+ +
+ Zurück +
+{% endblock %} diff --git a/gaehsnitz/templates/suff/staff_user.html b/gaehsnitz/templates/suff/staff_user.html new file mode 100644 index 0000000..796b6df --- /dev/null +++ b/gaehsnitz/templates/suff/staff_user.html @@ -0,0 +1,95 @@ +{% extends "suff/base.html" %} + +{% block content %} +

Crew-Ansicht

+

Buchen für {{ tab_user.username }}{% if is_anonymous_target %} (Bar / Anonym){% endif %}

+ +{% if booked_drink %} +
+ Gebucht: +1 {{ booked_drink.name }}{% if is_anonymous_target %} (bar bezahlt){% endif %} +
+{% endif %} + +{% if paid_toast %} +
+ Zahlung gespeichert. +
+{% endif %} + +{% if is_anonymous_target %} +

Anonyme Buchungen werden automatisch als bar bezahlt eingetragen.

+{% else %} +
+ Rechnung + {{ total|floatformat:2 }} € + {% if paid %} + Bezahlt + {{ paid|floatformat:2 }} € + Offen + {{ open_balance|floatformat:2 }} € + {% endif %} +
+ + {% if open_balance > 0 %} +

Zahlung eintragen

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

Getränk buchen

+
+ {% for drink in drinks %} +
+ {% csrf_token %} + + +
+ {% endfor %} +
+
+{% endif %} + +
+

Bisher gebucht

+ {% if consumption_list %} + {% regroup consumption_list by get_day_display as day_groups %} + {% for group in day_groups %} +
+

{{ group.grouper }}

+
    + {% for c in group.list %} +
  • + {% if c.created_at %}{{ c.created_at|date:"H:i" }}{% else %}—{% endif %} + {{ c.drink.name }} + + {% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %} + + {% if phase == "booking" %} + 🗑 + {% endif %} +
  • + {% endfor %} +
+
+ {% endfor %} + {% else %} +
+

🍺

+

Noch nichts gebucht.

+
+ {% endif %} +
+ + +{% endblock %} diff --git a/suff.md b/suff.md index b1f4175..3cac3fb 100644 --- a/suff.md +++ b/suff.md @@ -1,64 +1,112 @@ # Suff – drink booking tool -Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS, no CSS yet. +Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS. ## 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()`. +- `User.pin` (hashed CharField, 3 digits), separate from `password`. Strong password stays for `/admin/`. +- `PinBackend` authenticates by `username` + `pin`. `ModelBackend` first in `AUTHENTICATION_BACKENDS` so admin still needs strong password. +- PIN reset is **crew-only**. No self-reset, no random PINs, PINs never displayed. ## 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) +Username = `slugify(input)`. POST name: + +- not found → `create` mode → mandatory 3-digit PIN → create user → login +- found, has PIN → `login` mode → enter PIN +- found, no PIN, **no activity** (no Consumption + no UserPayment) → `claim` mode → set PIN → login +- found, no PIN, **has activity** → `no_pin.html` ("ask someone at the bar") ## 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. +- `/suff/me/` shows: greeting, total/paid/open balance, drink grid, day-grouped history. +- `+1` POST creates `Consumption(amount=1, day=current_weekday, for_free=False)`. +- Trash icon per row → `confirm_delete.html` → POST deletes own consumption (booking phase only). +- History grouped by festival day, newest-first per day. + +## Payments + +- `/suff/pay/` — user enters amount + method (cash/paypal/bank/other) + optional note. Creates `UserPayment`. Pre-fills with current `open_balance`. +- Method choices: `UserPayment.Method`. +- Open balance = sum(Consumption.price where !for_free) − sum(UserPayment.amount). + +## Crew (`is_staff`) + +Separate page tree under `/suff/staff/`: + +- `/suff/staff/` — alphabetical user list, anonymous gast on top, "Neuen Benutzer anlegen" link. +- `/suff/staff/new/` — register user. Name required, PIN optional 3 digits. +- `/suff/staff/u//` — book/pay/delete for that user. Mirrors `me.html`. +- `/suff/staff/u//pin/` — overwrite PIN (3 digits required, no clear). +- `/suff/staff/u//pay/` — record payment for that user. +- `/suff/staff/u//book//delete/` — delete consumption (and matching auto-payment if anon). + +Target username highlighted via `.staff-target` (cyan pill) on every crew page. + +## Anonymous walk-ins + +- Seeded user `anonym` (migration 0009). No PIN, never logs in. +- Crew books for anonymous via staff_user page → drink booking auto-creates matching `UserPayment(method=cash, note="Auto: ")` so balance always 0. +- Deleting an anonymous consumption removes one matching auto-payment. +- Anonymous has no pay page (404). PayPal walk-ins → register a real user instead. ## 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. +- Phases: `before` / `booking` / `closed`. +- Test window: 2026-05-15 → 2026-05-31. Original festival: 2026-06-11 → 2026-06-14. +- `closed` shows static page; outside booking, all action endpoints redirect or 404. +- `settings.PRODUCTION=False` forces `booking`. + +## Dashboard + +- `/suff/dashboard/` (staff only). Donations vs. expenses with progress bar, drink inventory rows, refinance %, per-user open balances, top spender, top drink, busiest day, top drinker per day. + +## Drink categories + +- `Drink.category`: beer / alc_free_beer / radler / alc_free_radler / soft / water. +- Buttons gradient-colored per category. Sorted by category in grid. ## Files - `gaehsnitz/auth_backends.py` — `PinBackend` -- `gaehsnitz/suff.py` — views + phase logic +- `gaehsnitz/suff.py` — all suff views + phase + crew helpers - `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/templates/suff/{base,name,pin,no_pin,me,pay,dashboard,closed,confirm_delete,staff_index,staff_user,staff_pay,staff_register,staff_pin_reset,staff_confirm_delete}.html` - `gaehsnitz/static/suff/{style.css,favicon.svg}` -- `gaehsnitz/migrations/0003_consumption_created_at_user_pin.py` -- Edits: `gaehsnitzproject/settings.py`, `gaehsnitzproject/urls.py`, `gaehsnitz/models.py` +- `gaehsnitz/migrations/0009_anonymous_user.py` — seeds `anonym` ## Frontend -Mobile-first styled. Dark theme matching GOA palette (`#161616` bg, `#EE9933`/`#FFCC77` amber accents, `#885522` brown borders). Standalone microsite — no nav to main GOA page. +Mobile-first dark theme. `#161616` bg, `#EE9933`/`#FFCC77` amber, `#885522` brown borders, `#66ddee` cyan for crew target. Drink buttons gradient-colored per category. Toast banner for booking confirmation. `:active` scale feedback. SVG favicon. -- Landing/login: GOA subhead + big "Suff" wordmark, `name` and `pin` forms with stacked label/input, large tap targets -- `me` page: 2-col drink button grid (4:3 aspect), stacked +1 / name / price; bordered total box; day-grouped history with zebra rows; emoji empty-state -- Booking confirmation: amber toast, 5s display, then 800ms collapse animation (pure CSS, no JS) -- `:active` scale(0.96) feedback on buttons + link-buttons -- `no_pin.html` link-buttons styled (primary + secondary) -- SVG favicon (🍺) +## Open ideas / next session -## Further ideas +### Entry fee handling + +Festival entry is flexible (10–30 €, sometimes paid as overpayment on drink tab). Options brainstormed: + +- **A. Implicit overpayment** — pre-fill pay form with `open_balance + 20`, treat anything above drinks as entry/donation. Zero schema change. +- **B. Quick-pick suggestions** — pay page offers buttons: `Nur Getränke (X €)`, `+10`, `+20`, `+30`, plus free-form. Recommended. +- **C. Explicit split** — separate entry field/checkbox. More UI, more accurate stats (`entry = paid − consumed`), more friction. + +Lean B. Stats can later derive entry as `paid − consumed_drinks_price` per user. + +### Pay-on-the-spot / quick-pay-cash + +Single button on `me` (and crew_user) page: "Offenen Betrag bar bezahlen" → creates `UserPayment(method=cash, amount=open_balance)`. Lets bar crew clear tab in one tap when guest pays cash directly. (Skipped for now, keep in mind.) + +### Round to nearest 10 € + +When pre-filling pay amount or showing quick-pick buttons, optionally round up to nearest multiple of 10 €. E.g. drinks 26.50 € → suggest 30, 40, 50. Easier cash handling, implicit donation/entry. + +### Prepay vs. pay-at-end + +Currently a single open balance. Could surface "Prepay 50 €" as a flow vs. "Pay at the end" — same data model, different framing. Maybe a "Vorkasse" preset on pay page. + +### Misc -- Color-code drink buttons (per-drink accent border or bg — Bier amber, Wasser blue, etc.) for fast visual recognition in dim light -- Drink icons/emoji per type -- Style phase pages (`before` / `closed` if non-404) - PWA manifest for add-to-homescreen -- Donation/free-drink flow if needed (currently admin-only via `for_free`) +- Drink icons/emoji per type +- Style phase pages (`before` / `closed`) +- Per-user QR for fast crew lookup at the bar