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
+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"}