Files
gaehsnitz/gaehsnitz/suff.py
T
flo 2d611dcac5 feat(drinks): add Sekt category with rainbow button styling
- 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>
2026-06-09 15:54:29 +02:00

977 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})