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:
+333
-24
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user