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:
@@ -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),
|
||||||
|
]
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+333
-24
@@ -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:
|
||||||
return render(
|
if _user_has_activity(existing):
|
||||||
request,
|
return render(
|
||||||
"suff/no_pin.html",
|
request,
|
||||||
{"phase": phase, "username": username},
|
"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_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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 (10–30 €, sometimes paid as overpayment on drink tab). Options brainstormed:
|
||||||
|
|
||||||
|
- **A. Implicit overpayment** — pre-fill pay form with `open_balance + 20`, treat anything above drinks as entry/donation. Zero schema change.
|
||||||
|
- **B. Quick-pick suggestions** — pay page offers buttons: `Nur Getränke (X €)`, `+10`, `+20`, `+30`, plus free-form. Recommended.
|
||||||
|
- **C. Explicit split** — separate entry field/checkbox. More UI, more accurate stats (`entry = paid − consumed`), more friction.
|
||||||
|
|
||||||
|
Lean B. Stats can later derive entry as `paid − consumed_drinks_price` per user.
|
||||||
|
|
||||||
|
### Pay-on-the-spot / quick-pay-cash
|
||||||
|
|
||||||
|
Single button on `me` (and crew_user) page: "Offenen Betrag bar bezahlen" → creates `UserPayment(method=cash, amount=open_balance)`. Lets bar crew clear tab in one tap when guest pays cash directly. (Skipped for now, keep in mind.)
|
||||||
|
|
||||||
|
### Round to nearest 10 €
|
||||||
|
|
||||||
|
When pre-filling pay amount or showing quick-pick buttons, optionally round up to nearest multiple of 10 €. E.g. drinks 26.50 € → suggest 30, 40, 50. Easier cash handling, implicit donation/entry.
|
||||||
|
|
||||||
|
### Prepay vs. pay-at-end
|
||||||
|
|
||||||
|
Currently a single open balance. Could surface "Prepay 50 €" as a flow vs. "Pay at the end" — same data model, different framing. Maybe a "Vorkasse" preset on pay page.
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
- Color-code drink buttons (per-drink accent border or bg — Bier amber, Wasser blue, etc.) for fast visual recognition in dim light
|
|
||||||
- Drink icons/emoji per type
|
|
||||||
- Style phase pages (`before` / `closed` if non-404)
|
|
||||||
- PWA manifest for add-to-homescreen
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user