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 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 18:05:20 +02:00
parent 980decb3fd
commit 50fc32c577
15 changed files with 828 additions and 71 deletions
@@ -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),
]
+56 -1
View File
@@ -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);
}
+333 -24
View File
@@ -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"}
+20
View File
@@ -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/<int:consumption_id>/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/<str:username>/", staff_user_view, name="staff_user"),
path("staff/u/<str:username>/pin/", staff_pin_reset_view, name="staff_pin_reset"),
path("staff/u/<str:username>/book/", staff_book_view, name="staff_book"),
path("staff/u/<str:username>/pay/", staff_pay_view, name="staff_pay"),
path(
"staff/u/<str:username>/book/<int:consumption_id>/delete/",
staff_delete_consumption_view,
name="staff_delete_consumption",
),
path("logout/", logout_view, name="logout"),
]
@@ -0,0 +1,19 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Buchung löschen?</h2>
<p>
Willst du wirklich
<strong>{{ consumption.drink.name }}</strong>
({% 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?
</p>
<form method="post" action="{% url 'suff:delete_consumption' consumption.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:me' %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
+4
View File
@@ -64,6 +64,9 @@
<span class="hist-price">
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
</span>
{% if phase == "booking" %}
<a href="{% url 'suff:delete_consumption' c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
{% endif %}
</li>
{% endfor %}
</ul>
@@ -80,6 +83,7 @@
{% if request.user.is_staff %}
<div class="link-row">
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Crew: Buchen für andere</a>
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Dashboard</a>
</div>
{% endif %}
+5 -9
View File
@@ -1,20 +1,16 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Keine PIN gesetzt</h2>
<h2>PIN benötigt</h2>
<p>
Für den Namen <b>{{ username }}</b> ist noch keine PIN hinterlegt.
Für den Namen <b>{{ username }}</b> ist keine PIN gesetzt, aber
der Account hat schon Buchungen oder Zahlungen.
</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.
Aus Sicherheit kannst du diesen Account nicht selbst übernehmen.
Bitte jemanden an der Bar, dir eine neue PIN zu setzen.
</p>
<div class="link-row">
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
<a href="/admin/" class="link-btn link-btn-secondary">Admin-Panel</a>
</div>
{% endblock %}
+4 -1
View File
@@ -5,6 +5,9 @@
<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>
{% elif mode == "claim" %}
<h2>Account übernehmen: {{ username }}</h2>
<p>Dieser Account hat noch keine PIN und keine Buchungen. Setz jetzt eine 3-stellige PIN, um ihn zu übernehmen.</p>
{% else %}
<h2>Hallo {{ username }}</h2>
<p>Gib deine 3-stellige PIN ein.</p>
@@ -20,7 +23,7 @@
minlength="3" required autofocus autocomplete="off" />
</label>
<button type="submit">
{% if mode == "create" %}Account anlegen{% else %}Login{% endif %}
{% if mode == "create" %}Account anlegen{% elif mode == "claim" %}Account übernehmen{% else %}Login{% endif %}
</button>
</form>
@@ -0,0 +1,24 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Buchung von <span class="staff-target">{{ tab_user.username }}</span> löschen?</h2>
<p>
Willst du wirklich
<strong>{{ consumption.drink.name }}</strong>
({% 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?
</p>
{% if tab_user.username == "anonym" %}
<p class="muted">Die zugehörige Bar-Zahlung wird ebenfalls entfernt.</p>
{% endif %}
<form method="post" action="{% url 'suff:staff_delete_consumption' tab_user.username consumption.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:staff_user' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Benutzer wählen</h2>
<p>Buchung oder Zahlung im Auftrag eines Benutzers eintragen.</p>
<div class="link-row">
<a href="{% url 'suff:staff_register' %}" class="link-btn">Neuen Benutzer anlegen</a>
</div>
{% if anonymous %}
<div class="link-row">
<a href="{% url 'suff:staff_user' anonymous.username %}" class="link-btn">
Anonymer Gast (Bar)
</a>
</div>
{% endif %}
<section>
<h3>Registrierte Benutzer</h3>
{% if users %}
<ul class="history">
{% for u in users %}
<li>
<span class="hist-what">
<a href="{% url 'suff:staff_user' u.username %}">{{ u.username }}</a>
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Keine Benutzer.</p>
{% endif %}
</section>
<div class="logout-form">
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Zahlung für <span class="staff-target">{{ tab_user.username }}</span></h2>
<section class="total-box">
<span class="total-label">Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
{% endif %}
<span class="total-label">Offen</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
</section>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="{% url 'suff:staff_pay' tab_user.username %}" class="pay-form">
{% csrf_token %}
<label>
Betrag (€)
<input type="number" name="amount" step="0.01" min="0.01"
value="{{ open_balance|floatformat:2 }}" required />
</label>
<label>
Methode
<select name="method" required>
<option value="">— wählen —</option>
{% for value, label in payment_methods %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
Notiz (optional)
<input type="text" name="note" maxlength="64" />
</label>
<button type="submit" class="btn-primary">Zahlung eintragen</button>
</form>
{% if user_payments %}
<section>
<h3>Bisherige Zahlungen</h3>
<ul class="history">
{% for p in user_payments %}
<li>
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} {{ p.note }}{% endif %}</span>
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
<div class="logout-form">
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>PIN setzen für <span class="staff-target">{{ tab_user.username }}</span></h2>
<p>Neue 3-stellige PIN eingeben. Eine bestehende PIN wird überschrieben.</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:staff_pin_reset' tab_user.username %}">
{% csrf_token %}
<label>
Neue PIN
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
maxlength="3" minlength="3" required autofocus autocomplete="off" />
</label>
<button type="submit" class="btn-primary">PIN speichern</button>
</form>
<div class="logout-form">
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Neuen Benutzer anlegen</h2>
<p>Name eingeben. PIN ist optional — kann später bei Bedarf gesetzt werden.</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:staff_register' %}">
{% csrf_token %}
<label>
Name
<input type="text" name="name" maxlength="150" required autofocus
value="{{ prefill_name }}" />
</label>
<label>
PIN (optional, 3 Ziffern)
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
maxlength="3" autocomplete="off" value="{{ prefill_pin }}" />
</label>
<button type="submit" class="btn-primary">Anlegen</button>
</form>
<div class="logout-form">
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Buchen für <span class="staff-target">{{ tab_user.username }}</span>{% if is_anonymous_target %} (Bar / Anonym){% endif %}</h2>
{% if booked_drink %}
<div class="toast" role="status">
Gebucht: +1 {{ booked_drink.name }}{% if is_anonymous_target %} (bar bezahlt){% endif %}
</div>
{% endif %}
{% if paid_toast %}
<div class="toast" role="status">
Zahlung gespeichert.
</div>
{% endif %}
{% if is_anonymous_target %}
<p class="muted">Anonyme Buchungen werden automatisch als bar bezahlt eingetragen.</p>
{% else %}
<section class="total-box">
<span class="total-label">Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
<span class="total-label">Offen</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
{% endif %}
</section>
{% if open_balance > 0 %}
<p><a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-primary">Zahlung eintragen</a></p>
{% endif %}
{% endif %}
{% if phase == "booking" %}
<section>
<h3>Getränk buchen</h3>
<div class="drink-grid">
{% for drink in drinks %}
<form method="post" action="{% url 'suff:staff_book' tab_user.username %}">
{% csrf_token %}
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
<button type="submit" class="drink-btn drink-btn-{{ drink.category }}">
<span class="drink-plus">+1</span>
<span class="drink-name">{{ drink.name }}</span>
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
</button>
</form>
{% endfor %}
</div>
</section>
{% endif %}
<section>
<h3>Bisher gebucht</h3>
{% if consumption_list %}
{% regroup consumption_list by get_day_display as day_groups %}
{% for group in day_groups %}
<div class="day-group">
<h4 class="day-heading">{{ group.grouper }}</h4>
<ul class="history">
{% for c in group.list %}
<li>
<span class="hist-when">{% if c.created_at %}{{ c.created_at|date:"H:i" }}{% else %}—{% endif %}</span>
<span class="hist-what">{{ c.drink.name }}</span>
<span class="hist-price">
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
</span>
{% if phase == "booking" %}
<a href="{% url 'suff:staff_delete_consumption' tab_user.username c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p class="empty-emoji">🍺</p>
<p>Noch nichts gebucht.</p>
</div>
{% endif %}
</section>
<div class="link-row">
{% if not is_anonymous_target %}
<a href="{% url 'suff:staff_pin_reset' tab_user.username %}" class="link-btn link-btn-secondary">PIN setzen / zurücksetzen</a>
{% endif %}
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Anderen Benutzer wählen</a>
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück zu mir</a>
</div>
{% endblock %}
+84 -36
View File
@@ -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 `<id>/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/<name>/` — book/pay/delete for that user. Mirrors `me.html`.
- `/suff/staff/u/<name>/pin/` — overwrite PIN (3 digits required, no clear).
- `/suff/staff/u/<name>/pay/` — record payment for that user.
- `/suff/staff/u/<name>/book/<id>/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: <drink>")` 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 (1030 €, 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