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 { .history li {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
background-color: rgba(80, 40, 10, 0.2); background-color: rgba(80, 40, 10, 0.2);
@@ -294,6 +295,60 @@ section {
font-size: 0.95rem; 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) { .history li:nth-child(odd) {
background-color: rgba(80, 40, 10, 0.35); background-color: rgba(80, 40, 10, 0.35);
} }
+328 -19
View File
@@ -18,6 +18,8 @@ from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment,
User = get_user_model() User = get_user_model()
ANONYMOUS_USERNAME = "anonym"
BERLIN = ZoneInfo("Europe/Berlin") BERLIN = ZoneInfo("Europe/Berlin")
# TEST WINDOW (2026-05-15 2026-05-31): enabled early for pre-festival testing. # 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. # 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] 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): def _tab_context(user):
year = current_year() year = current_year()
consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id") 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")) return HttpResponseRedirect(reverse("suff:pin"))
if not existing.pin: if not existing.pin:
if _user_has_activity(existing):
return render( return render(
request, request,
"suff/no_pin.html", "suff/no_pin.html",
{"phase": phase, "username": username}, {"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_username"] = username
request.session["pending_mode"] = "login" request.session["pending_mode"] = "login"
@@ -128,7 +172,7 @@ def pin_view(request):
return redirect return redirect
username = request.session.get("pending_username") username = request.session.get("pending_username")
mode = request.session.get("pending_mode") 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")) return HttpResponseRedirect(reverse("suff:name"))
error = None error = None
@@ -151,6 +195,23 @@ def pin_view(request):
request.session.pop("pending_username", None) request.session.pop("pending_username", None)
request.session.pop("pending_mode", None) request.session.pop("pending_mode", None)
return HttpResponseRedirect(reverse("suff:me")) 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: else:
authed = authenticate(request, username=username, pin=pin) authed = authenticate(request, username=username, pin=pin)
if authed is None: if authed is None:
@@ -179,24 +240,7 @@ def me_view(request):
redirect, phase = _require_open(request) redirect, phase = _require_open(request)
if redirect: if redirect:
return redirect return redirect
drinks = ( drinks = _drink_grid_qs() if phase == "booking" else Drink.objects.none()
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()
)
booked_drink = None booked_drink = None
booked_id = request.GET.get("booked") booked_id = request.GET.get("booked")
if booked_id: if booked_id:
@@ -226,6 +270,9 @@ def book_view(request):
if phase != "booking": if phase != "booking":
raise Http404 raise Http404
if _is_anonymous(request.user):
raise Http404
drink_id = request.POST.get("drink_id") drink_id = request.POST.get("drink_id")
try: try:
drink = Drink.objects.get(pk=int(drink_id)) 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}") 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 @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def pay_view(request): def pay_view(request):
@@ -279,6 +351,243 @@ def pay_view(request):
return render(request, "suff/pay.html", context) 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"} DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
+20
View File
@@ -4,11 +4,19 @@ from gaehsnitz.suff import (
book_view, book_view,
closed_view, closed_view,
dashboard_view, dashboard_view,
delete_consumption_view,
logout_view, logout_view,
me_view, me_view,
name_view, name_view,
pay_view, pay_view,
pin_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" app_name = "suff"
@@ -19,7 +27,19 @@ urlpatterns = [
path("pin/", pin_view, name="pin"), path("pin/", pin_view, name="pin"),
path("me/", me_view, name="me"), path("me/", me_view, name="me"),
path("book/", book_view, name="book"), 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("pay/", pay_view, name="pay"),
path("dashboard/", dashboard_view, name="dashboard"), 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"), 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"> <span class="hist-price">
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %} {% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
</span> </span>
{% if phase == "booking" %}
<a href="{% url 'suff:delete_consumption' c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -80,6 +83,7 @@
{% if request.user.is_staff %} {% if request.user.is_staff %}
<div class="link-row"> <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> <a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Dashboard</a>
</div> </div>
{% endif %} {% endif %}
+5 -9
View File
@@ -1,20 +1,16 @@
{% extends "suff/base.html" %} {% extends "suff/base.html" %}
{% block content %} {% block content %}
<h2>Keine PIN gesetzt</h2> <h2>PIN benötigt</h2>
<p> <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>
<p> <p>
Das ist ein Staff-Account. Aus Sicherheitsgründen kann die PIN für solche Aus Sicherheit kannst du diesen Account nicht selbst übernehmen.
Accounts nicht selbst gesetzt werden sonst könnte sich jeder mit dem Bitte jemanden an der Bar, dir eine neue PIN zu setzen.
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>
<div class="link-row"> <div class="link-row">
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a> <a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
<a href="/admin/" class="link-btn link-btn-secondary">Admin-Panel</a>
</div> </div>
{% endblock %} {% endblock %}
+4 -1
View File
@@ -5,6 +5,9 @@
<h2>Neuer Account: {{ username }}</h2> <h2>Neuer Account: {{ username }}</h2>
<p>Merk dir diesen Namen: <b>{{ username }}</b>. Du brauchst ihn beim nächsten Login.</p> <p>Merk dir diesen Namen: <b>{{ username }}</b>. Du brauchst ihn beim nächsten Login.</p>
<p>Wähl eine 3-stellige PIN.</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 %} {% else %}
<h2>Hallo {{ username }}</h2> <h2>Hallo {{ username }}</h2>
<p>Gib deine 3-stellige PIN ein.</p> <p>Gib deine 3-stellige PIN ein.</p>
@@ -20,7 +23,7 @@
minlength="3" required autofocus autocomplete="off" /> minlength="3" required autofocus autocomplete="off" />
</label> </label>
<button type="submit"> <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> </button>
</form> </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 # 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 ## 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. - `User.pin` (hashed CharField, 3 digits), separate from `password`. Strong password stays for `/admin/`.
- `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. - `PinBackend` authenticates by `username` + `pin`. `ModelBackend` first in `AUTHENTICATION_BACKENDS` so admin still needs 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. - PIN reset is **crew-only**. No self-reset, no random PINs, PINs never displayed.
- 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 ## Name flow
- Username = `slugify(input)` (e.g. "Flo Hä!" → "flo-ha"). Slug shown back so user can memorize it. Username = `slugify(input)`. POST name:
- POST name → check existence:
- not found → set new PIN → create user → login - not found → `create` mode → mandatory 3-digit PIN → create user → login
- found, has PIN → enter PIN → login - found, has PIN → `login` mode → enter PIN
- found, no PIN`no_pin.html` (ask admin) - 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 ## Booking
- `/suff/me/` shows: greeting (slug), running paid total, full consumption history with timestamps, drink buttons. - `/suff/me/` shows: greeting, total/paid/open balance, drink grid, day-grouped history.
- Each drink = `+1` POST form. Server creates `Consumption(amount=1, day=current_weekday, for_free=False, created_at=auto)`. - `+1` POST creates `Consumption(amount=1, day=current_weekday, for_free=False)`.
- No undo, no delete, no edit. No special bartender role. - Trash icon per row → `confirm_delete.html` → POST deletes own consumption (booking phase only).
- History sorted newest-first, `created_at` shown as `Do 18:42` etc. - 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) ## Time gating (Berlin tz)
- Phases: `before` / `booking` / `readonly` / `closed`. - Phases: `before` / `booking` / `closed`.
- Booking allowed Thu 2026-06-11 00:00 → Sun 2026-06-14 23:59. - Test window: 2026-05-15 → 2026-05-31. Original festival: 2026-06-11 → 2026-06-14.
- Read-only until Sun 2026-06-21 23:59. - `closed` shows static page; outside booking, all action endpoints redirect or 404.
- After: every `/suff/` URL returns 404. - `settings.PRODUCTION=False` forces `booking`.
- Local dev: `settings.PRODUCTION=False` forces `booking` phase always.
## 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 ## Files
- `gaehsnitz/auth_backends.py``PinBackend` - `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/suff_urls.py` — routes
- `gaehsnitz/admin.py``SetPinForm` + `set_pin_view` - `gaehsnitz/admin.py``SetPinForm` + `set_pin_view`
- `gaehsnitz/templates/suff/{base,name,pin,no_pin,me}.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/templates/admin/gaehsnitz/user/set_pin.html`
- `gaehsnitz/static/suff/{style.css,favicon.svg}` - `gaehsnitz/static/suff/{style.css,favicon.svg}`
- `gaehsnitz/migrations/0003_consumption_created_at_user_pin.py` - `gaehsnitz/migrations/0009_anonymous_user.py` — seeds `anonym`
- Edits: `gaehsnitzproject/settings.py`, `gaehsnitzproject/urls.py`, `gaehsnitz/models.py`
## Frontend ## 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 ## Open ideas / next session
- `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 (🍺)
## 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 - 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