2d611dcac5
- Add Drink.Category.sekt for consignment alcoholic drinks - Order sekt under "mit Alkohol" section, after Radler - Rainbow gradient CSS for .drink-btn-sekt - Rebalance all drink button brightness to match rainbow midpoint - Seed Sekty Drink (3.50€, no deposit, no purchase price) - Update Sternburg Export 10.99→9.49, Altenburger Helles 15.99→13.99 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
977 lines
32 KiB
Python
977 lines
32 KiB
Python
import math
|
||
from datetime import datetime, timedelta
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from django.conf import settings
|
||
from django.contrib.admin.views.decorators import staff_member_required
|
||
from django.contrib.auth import authenticate, login, logout
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.contrib.auth import get_user_model
|
||
from django.db.models import Case, CharField, Count, F, IntegerField, Sum, Value, When
|
||
from django.http import Http404, HttpResponseRedirect
|
||
from django.shortcuts import render
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
from django.utils.text import slugify
|
||
from django.views.decorators.http import require_http_methods
|
||
|
||
from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
|
||
|
||
User = get_user_model()
|
||
|
||
ANONYMOUS_USERNAME = "anonym"
|
||
|
||
BERLIN = ZoneInfo("Europe/Berlin")
|
||
# Festival window: 2026-05-30 10:00 – 2026-06-14 22:00 Berlin time.
|
||
BOOKING_START = datetime(2026, 5, 30, 10, 0, 0, tzinfo=BERLIN)
|
||
BOOKING_END = datetime(2026, 6, 14, 22, 0, 0, tzinfo=BERLIN)
|
||
|
||
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
|
||
DAY_CUTOFF_HOUR = 6
|
||
|
||
|
||
def _shifted_local(dt):
|
||
"""Local Berlin time minus 6h — so 05:59 belongs to previous calendar day."""
|
||
return dt.astimezone(BERLIN) - timedelta(hours=DAY_CUTOFF_HOUR)
|
||
|
||
|
||
def _now():
|
||
return timezone.now().astimezone(BERLIN)
|
||
|
||
|
||
def _phase():
|
||
if not settings.PRODUCTION:
|
||
return "booking"
|
||
now = _now()
|
||
if now < BOOKING_START:
|
||
return "before"
|
||
if now <= BOOKING_END:
|
||
return "booking"
|
||
return "closed"
|
||
|
||
|
||
def _require_open(request):
|
||
"""Redirect to closed page when tool is not in booking phase."""
|
||
phase = _phase()
|
||
if phase in ("before", "closed"):
|
||
return HttpResponseRedirect(reverse("suff:closed")), None
|
||
return None, phase
|
||
|
||
|
||
def _current_festival_day():
|
||
# Day rolls at 06:00 Berlin: bookings before 06:00 count as previous day.
|
||
weekday = _shifted_local(timezone.now()).weekday()
|
||
return DAY_BY_WEEKDAY.get(weekday, 4)
|
||
|
||
|
||
def _normalize_name(raw):
|
||
return slugify(raw or "")[:20]
|
||
|
||
|
||
def _username_error(username):
|
||
if len(username) < 2:
|
||
return "Name muss mindestens 2 Zeichen lang sein."
|
||
return None
|
||
|
||
|
||
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
|
||
|
||
|
||
_WEAK_PINS = {"123", "234", "345", "456", "567", "678", "789", "987", "876", "765", "654", "543", "432", "321"} | {d * 3 for d in "0123456789"}
|
||
|
||
|
||
def _pin_error(pin):
|
||
if not (pin.isdigit() and len(pin) == 3):
|
||
return "PIN muss aus genau 3 Ziffern bestehen."
|
||
if pin in _WEAK_PINS:
|
||
return "Diese PIN ist zu einfach. Bitte eine andere wählen."
|
||
return None
|
||
|
||
|
||
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(
|
||
alcohol_order=Case(
|
||
When(category__in=["beer", "radler", "sekt"], then=Value(0)),
|
||
default=Value(1),
|
||
output_field=IntegerField(),
|
||
),
|
||
alcohol_label=Case(
|
||
When(category__in=["beer", "radler", "sekt"], then=Value("mit Alkohol")),
|
||
default=Value("ohne Alkohol"),
|
||
output_field=CharField(),
|
||
),
|
||
category_order=Case(
|
||
When(category="beer", then=Value(0)),
|
||
When(category="radler", then=Value(1)),
|
||
When(category="sekt", then=Value(2)),
|
||
When(category="alc_free_beer", then=Value(3)),
|
||
When(category="alc_free_radler", then=Value(4)),
|
||
When(category="soft", then=Value(5)),
|
||
When(category="water", then=Value(6)),
|
||
default=Value(99),
|
||
output_field=IntegerField(),
|
||
),
|
||
)
|
||
.order_by("alcohol_order", "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")
|
||
total = (
|
||
user.paid_drinks.filter(drink__year=year)
|
||
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
|
||
.aggregate(total=Sum("cost"))["total"]
|
||
or 0
|
||
)
|
||
payments = user.user_payments.filter(created_at__year=year).order_by("-created_at")
|
||
paid = payments.aggregate(sum=Sum("amount"))["sum"] or 0
|
||
return {
|
||
"tab_user": user,
|
||
"consumption_list": consumption,
|
||
"total": total,
|
||
"paid": paid,
|
||
"open_balance": total - paid,
|
||
"user_payments": payments,
|
||
"payment_methods": [(m, l) for m, l in UserPayment.Method.choices if m != "other"],
|
||
}
|
||
|
||
|
||
@require_http_methods(["GET", "POST"])
|
||
def name_view(request):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
if request.user.is_authenticated:
|
||
return HttpResponseRedirect(reverse("suff:me"))
|
||
|
||
error = None
|
||
if request.method == "POST":
|
||
raw_name = request.POST.get("name", "")
|
||
username = _normalize_name(raw_name)
|
||
if not username or (error := _username_error(username)):
|
||
error = error or "Bitte einen Namen eingeben."
|
||
else:
|
||
try:
|
||
existing = User.objects.get(username=username)
|
||
except User.DoesNotExist:
|
||
existing = None
|
||
|
||
if existing is None:
|
||
request.session["pending_username"] = username
|
||
request.session["pending_mode"] = "create"
|
||
return HttpResponseRedirect(reverse("suff:pin"))
|
||
|
||
if not existing.pin:
|
||
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"
|
||
return HttpResponseRedirect(reverse("suff:pin"))
|
||
|
||
return render(request, "suff/name.html", {"phase": phase, "error": error})
|
||
|
||
|
||
@require_http_methods(["GET", "POST"])
|
||
def pin_view(request):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
username = request.session.get("pending_username")
|
||
mode = request.session.get("pending_mode")
|
||
if not username or mode not in ("create", "login", "claim"):
|
||
return HttpResponseRedirect(reverse("suff:name"))
|
||
|
||
error = None
|
||
if request.method == "POST":
|
||
pin = request.POST.get("pin", "")
|
||
if error := _pin_error(pin):
|
||
pass
|
||
elif mode == "create":
|
||
if User.objects.filter(username=username).exists():
|
||
error = "Name bereits vergeben."
|
||
return HttpResponseRedirect(reverse("suff:name"))
|
||
user = User(username=username)
|
||
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"))
|
||
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:
|
||
error = "Falsche PIN."
|
||
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"))
|
||
|
||
return render(
|
||
request,
|
||
"suff/pin.html",
|
||
{
|
||
"phase": phase,
|
||
"error": error,
|
||
"mode": mode,
|
||
"username": username,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def change_pin_view(request):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
|
||
error = None
|
||
if request.method == "POST":
|
||
pin = request.POST.get("pin", "")
|
||
if error := _pin_error(pin):
|
||
pass
|
||
else:
|
||
request.user.set_pin(pin)
|
||
request.user.save()
|
||
return HttpResponseRedirect(reverse("suff:me"))
|
||
|
||
return render(request, "suff/change_pin.html", {"phase": phase, "error": error})
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET"])
|
||
def me_view(request):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
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(request.user)
|
||
context.update(
|
||
{
|
||
"phase": phase,
|
||
"drinks": drinks,
|
||
"current_day": _current_festival_day(),
|
||
"booked_drink": booked_drink,
|
||
"paid_toast": request.GET.get("paid") == "1",
|
||
}
|
||
)
|
||
return render(request, "suff/me.html", context)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def book_view(request):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
if phase != "booking":
|
||
raise Http404
|
||
|
||
if _is_anonymous(request.user):
|
||
raise Http404
|
||
|
||
drink_id = request.POST.get("drink_id")
|
||
booking_mode = request.POST.get("booking_mode", "normal")
|
||
for_free = booking_mode == "for_free"
|
||
cash_paid = booking_mode == "cash_paid"
|
||
try:
|
||
drink = Drink.objects.get(pk=int(drink_id))
|
||
except (Drink.DoesNotExist, TypeError, ValueError):
|
||
return HttpResponseRedirect(reverse("suff:me"))
|
||
|
||
Consumption.objects.create(
|
||
user=request.user,
|
||
drink=drink,
|
||
amount=1,
|
||
day=_current_festival_day(),
|
||
for_free=for_free,
|
||
)
|
||
if cash_paid:
|
||
UserPayment.objects.create(
|
||
user=request.user,
|
||
amount=drink.sale_price_per_bottle,
|
||
method=UserPayment.Method.cash,
|
||
note=f"Auto: {drink.name}",
|
||
)
|
||
free_suffix = "&free=1" if for_free else ""
|
||
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}{free_suffix}")
|
||
|
||
|
||
@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 delete_payment_view(request, payment_id):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
if phase != "booking":
|
||
raise Http404
|
||
|
||
try:
|
||
payment = UserPayment.objects.get(pk=payment_id, user=request.user)
|
||
except UserPayment.DoesNotExist:
|
||
raise Http404
|
||
|
||
if request.method == "POST":
|
||
payment.delete()
|
||
return HttpResponseRedirect(reverse("suff:pay"))
|
||
|
||
return render(
|
||
request,
|
||
"suff/confirm_delete_payment.html",
|
||
{"phase": phase, "payment": payment},
|
||
)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["GET", "POST"])
|
||
def pay_view(request):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
|
||
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=request.user,
|
||
amount=amount,
|
||
method=method,
|
||
note=note,
|
||
)
|
||
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
|
||
|
||
context = _tab_context(request.user)
|
||
open_balance = max(float(context["open_balance"]), 0.0)
|
||
drinks_rounded = int(math.ceil(open_balance / 5.0) * 5) if open_balance > 0 else 0
|
||
suggestions = [
|
||
{"amount": drinks_rounded + d, "entry": round(drinks_rounded + d - open_balance, 2)}
|
||
for d in (10, 15, 20, 25, 30)
|
||
]
|
||
prefill_raw = request.GET.get("amount")
|
||
try:
|
||
prefill_amount = float(prefill_raw) if prefill_raw else None
|
||
if prefill_amount is not None and prefill_amount <= 0:
|
||
prefill_amount = None
|
||
except ValueError:
|
||
prefill_amount = None
|
||
if prefill_amount is None:
|
||
prefill_amount = float(suggestions[2]["amount"])
|
||
prefill_value = f"{prefill_amount:.2f}"
|
||
context.update(
|
||
{
|
||
"phase": phase,
|
||
"error": error,
|
||
"drinks_rounded": drinks_rounded,
|
||
"suggestions": suggestions,
|
||
"prefill_amount": prefill_amount,
|
||
"prefill_value": prefill_value,
|
||
"open_balance_url": f"{open_balance:.2f}",
|
||
}
|
||
)
|
||
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 error := _username_error(username):
|
||
pass
|
||
elif User.objects.filter(username=username).exists():
|
||
error = "Name bereits vergeben."
|
||
elif pin and (error := _pin_error(pin)):
|
||
pass
|
||
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 error := _pin_error(pin):
|
||
pass
|
||
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,
|
||
"booked_free": request.GET.get("free") == "1",
|
||
"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")
|
||
booking_mode = request.POST.get("booking_mode", "normal")
|
||
for_free = booking_mode == "for_free"
|
||
cash_paid = booking_mode == "cash_paid"
|
||
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=for_free,
|
||
)
|
||
if cash_paid or (_is_anonymous(target) and not for_free):
|
||
UserPayment.objects.create(
|
||
user=target,
|
||
amount=drink.sale_price_per_bottle,
|
||
method=UserPayment.Method.cash,
|
||
note=f"Auto: {drink.name}",
|
||
)
|
||
suffix = "&free=1" if for_free else ""
|
||
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}{suffix}")
|
||
|
||
|
||
@_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_payment_view(request, username, payment_id):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
if phase != "booking":
|
||
raise Http404
|
||
|
||
target = _get_staff_target(username)
|
||
try:
|
||
payment = UserPayment.objects.get(pk=payment_id, user=target)
|
||
except UserPayment.DoesNotExist:
|
||
raise Http404
|
||
|
||
if request.method == "POST":
|
||
payment.delete()
|
||
return HttpResponseRedirect(reverse("suff:staff_pay", args=[username]))
|
||
|
||
return render(
|
||
request,
|
||
"suff/staff_confirm_delete_payment.html",
|
||
{"phase": phase, "payment": payment, "tab_user": target},
|
||
)
|
||
|
||
|
||
@_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"}
|
||
|
||
|
||
@staff_member_required
|
||
@require_http_methods(["GET"])
|
||
def dashboard_view(request):
|
||
year = current_year()
|
||
|
||
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||
total_costs = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||
user_payments_total = UserPayment.objects.filter(created_at__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||
|
||
drinks = list(Drink.objects.filter(year=year).order_by("name"))
|
||
drink_rows = [
|
||
{
|
||
"name": d.name,
|
||
"sold": d.bottles_sold,
|
||
"total": d.bottles_total,
|
||
"balance": d.balance,
|
||
}
|
||
for d in drinks
|
||
]
|
||
sales_revenue = sum((d.sale_price_total for d in drinks), 0)
|
||
purchase_cost = sum((d.purchase_price_total for d in drinks), 0)
|
||
free_drinks_value = sum((d.giveaway_purchase_value for d in drinks), 0)
|
||
unsold_purchase_value = sum((d.remaining_purchase_value for d in drinks), 0)
|
||
unsold_sale_value = sum((d.bottles_remaining * d.sale_price_per_bottle for d in drinks), 0)
|
||
|
||
income_total = total_donations + user_payments_total
|
||
finance_balance = income_total - total_costs
|
||
finance_pct = int(round((income_total / total_costs) * 100)) if total_costs else 0
|
||
finance_pct_capped = min(finance_pct, 100)
|
||
|
||
user_rows = []
|
||
for user in User.objects.all():
|
||
consumed = user.consumed_drinks_price
|
||
if not consumed:
|
||
continue
|
||
paid = user.paid_amount
|
||
open_amount = consumed - paid
|
||
if open_amount <= 0:
|
||
continue
|
||
user_rows.append(
|
||
{
|
||
"username": user.username,
|
||
"consumed": consumed,
|
||
"paid": paid,
|
||
"open": open_amount,
|
||
}
|
||
)
|
||
user_rows.sort(key=lambda r: r["open"], reverse=True)
|
||
|
||
top_spender = None
|
||
if user_rows:
|
||
top = max(user_rows, key=lambda r: r["consumed"])
|
||
top_spender = {"username": top["username"], "total": top["consumed"]}
|
||
|
||
drink_amounts = (
|
||
Consumption.objects.filter(drink__year=year, for_free=False)
|
||
.values("drink__name")
|
||
.annotate(amount=Sum("amount"))
|
||
.order_by("-amount")
|
||
)
|
||
top_drink = None
|
||
if drink_amounts:
|
||
top_drink = {"name": drink_amounts[0]["drink__name"], "amount": drink_amounts[0]["amount"]}
|
||
|
||
day_amounts = (
|
||
Consumption.objects.filter(drink__year=year, for_free=False)
|
||
.values("day")
|
||
.annotate(amount=Sum("amount"))
|
||
.order_by("-amount")
|
||
)
|
||
busiest_day = None
|
||
if day_amounts:
|
||
d = day_amounts[0]
|
||
busiest_day = {"label": DAY_LABELS.get(d["day"], "?"), "amount": d["amount"]}
|
||
|
||
top_per_day = []
|
||
for day_num, label in DAY_LABELS.items():
|
||
rows = (
|
||
Consumption.objects.filter(drink__year=year, day=day_num, for_free=False)
|
||
.values("user__username")
|
||
.annotate(amount=Sum("amount"))
|
||
.order_by("-amount")
|
||
)
|
||
if rows:
|
||
r = rows[0]
|
||
top_per_day.append({"label": label, "username": r["user__username"], "amount": r["amount"]})
|
||
|
||
cons_qs = Consumption.objects.filter(drink__year=year)
|
||
paid_cons_qs = cons_qs.filter(for_free=False)
|
||
|
||
# Time facts
|
||
first_cons = cons_qs.exclude(created_at__isnull=True).order_by("created_at").select_related("user", "drink").first()
|
||
last_cons = cons_qs.exclude(created_at__isnull=True).order_by("-created_at").select_related("user", "drink").first()
|
||
|
||
# Day rolls at 06:00 Berlin: 05:59 counts as late "previous day", 07:00 = early "today".
|
||
# Compute time-of-day relative to 06:00 cutoff.
|
||
earliest_per_user = None
|
||
latest_per_user = None
|
||
earliest_key = None
|
||
latest_key = None
|
||
hour_buckets = {}
|
||
for c in cons_qs.exclude(created_at__isnull=True).values("user__username", "created_at", "amount"):
|
||
local = c["created_at"].astimezone(BERLIN)
|
||
# minutes after 06:00 cutoff (0 = 06:00, 1439 = 05:59 next morning)
|
||
shifted = (local.hour * 60 + local.minute - DAY_CUTOFF_HOUR * 60) % (24 * 60)
|
||
if earliest_key is None or shifted < earliest_key:
|
||
earliest_key = shifted
|
||
earliest_per_user = {"user__username": c["user__username"], "t": c["created_at"]}
|
||
if latest_key is None or shifted > latest_key:
|
||
latest_key = shifted
|
||
latest_per_user = {"user__username": c["user__username"], "t": c["created_at"]}
|
||
hour_buckets[local.hour] = hour_buckets.get(local.hour, 0) + (c["amount"] or 0)
|
||
|
||
golden_hour = None
|
||
if hour_buckets:
|
||
top_hour, top_amount = max(hour_buckets.items(), key=lambda kv: kv[1])
|
||
golden_hour = {
|
||
"label": f"{top_hour:02d} – {(top_hour + 1) % 24:02d} Uhr",
|
||
"amount": top_amount,
|
||
}
|
||
|
||
# Drink mix / variety
|
||
variety_rows = (
|
||
paid_cons_qs.values("user__username")
|
||
.annotate(distinct=Count("drink", distinct=True))
|
||
.order_by("-distinct")
|
||
)
|
||
variety_champ = variety_rows[0] if variety_rows and variety_rows[0]["distinct"] > 1 else None
|
||
|
||
cat_rows = (
|
||
paid_cons_qs.values("drink__category")
|
||
.annotate(amount=Sum("amount"))
|
||
)
|
||
cat_total = sum(r["amount"] for r in cat_rows) or 0
|
||
beer_amount = sum(r["amount"] for r in cat_rows if r["drink__category"] == "beer")
|
||
beer_share = int(round(beer_amount / cat_total * 100)) if cat_total else 0
|
||
|
||
def _top_in_categories(cats, label):
|
||
rows = (
|
||
paid_cons_qs.filter(drink__category__in=cats)
|
||
.values("user__username")
|
||
.annotate(amount=Sum("amount"))
|
||
.order_by("-amount")
|
||
)
|
||
if rows:
|
||
return {"label": label, "username": rows[0]["user__username"], "amount": rows[0]["amount"]}
|
||
return None
|
||
|
||
alcfree_top = _top_in_categories(["alc_free_beer", "alc_free_radler"], "Alkoholfrei-Held")
|
||
radler_top = _top_in_categories(["radler"], "Radler-Fan")
|
||
water_top = _top_in_categories(["water"], "Wassertrinker")
|
||
|
||
# Money / behavior
|
||
overpay_rows = []
|
||
for user in User.objects.all():
|
||
consumed = user.consumed_drinks_price or 0
|
||
paid = user.paid_amount or 0
|
||
if paid > consumed and consumed > 0:
|
||
overpay_rows.append({"username": user.username, "tip": paid - consumed})
|
||
overpay_rows.sort(key=lambda r: r["tip"], reverse=True)
|
||
biggest_tip = overpay_rows[0] if overpay_rows else None
|
||
total_entry = sum(r["tip"] for r in overpay_rows)
|
||
|
||
method_rows = (
|
||
UserPayment.objects.filter(created_at__year=year)
|
||
.values("method")
|
||
.annotate(s=Sum("amount"))
|
||
)
|
||
method_split = {r["method"]: r["s"] for r in method_rows}
|
||
cash_total = method_split.get(UserPayment.Method.cash, 0)
|
||
|
||
# Free drinks
|
||
free_recipient_rows = (
|
||
cons_qs.filter(for_free=True)
|
||
.values("user__username")
|
||
.annotate(amount=Sum("amount"))
|
||
.order_by("-amount")
|
||
)
|
||
top_free_recipient = free_recipient_rows[0] if free_recipient_rows else None
|
||
free_total_count = cons_qs.filter(for_free=True).aggregate(s=Sum("amount"))["s"] or 0
|
||
|
||
|
||
context = {
|
||
"year": year,
|
||
"total_donations": total_donations,
|
||
"total_costs": total_costs,
|
||
"user_payments_total": user_payments_total,
|
||
"income_total": income_total,
|
||
"finance_balance": finance_balance,
|
||
"finance_pct": finance_pct,
|
||
"finance_pct_capped": finance_pct_capped,
|
||
"drink_rows": drink_rows,
|
||
"sales_revenue": sales_revenue,
|
||
"purchase_cost": purchase_cost,
|
||
"free_drinks_value": free_drinks_value,
|
||
"unsold_purchase_value": unsold_purchase_value,
|
||
"unsold_sale_value": unsold_sale_value,
|
||
"user_rows": user_rows,
|
||
"top_spender": top_spender,
|
||
"top_drink": top_drink,
|
||
"busiest_day": busiest_day,
|
||
"top_per_day": top_per_day,
|
||
"first_cons": first_cons,
|
||
"last_cons": last_cons,
|
||
"earliest_per_user": earliest_per_user,
|
||
"latest_per_user": latest_per_user,
|
||
"golden_hour": golden_hour,
|
||
"variety_champ": variety_champ,
|
||
"beer_share": beer_share,
|
||
"alcfree_top": alcfree_top,
|
||
"radler_top": radler_top,
|
||
"water_top": water_top,
|
||
"biggest_tip": biggest_tip,
|
||
"total_entry": total_entry,
|
||
"method_split": method_split,
|
||
"cash_total": cash_total,
|
||
"top_free_recipient": top_free_recipient,
|
||
"free_total_count": free_total_count,
|
||
}
|
||
return render(request, "suff/dashboard.html", context)
|
||
|
||
|
||
@require_http_methods(["POST"])
|
||
def logout_view(request):
|
||
logout(request)
|
||
return HttpResponseRedirect(reverse("suff:name"))
|
||
|
||
|
||
@require_http_methods(["GET"])
|
||
def closed_view(request):
|
||
phase = _phase()
|
||
if phase == "booking":
|
||
return HttpResponseRedirect(reverse("suff:name"))
|
||
return render(request, "suff/closed.html", {"phase": phase, "booking_start": BOOKING_START})
|