Add free-drink toggle, payment deletion, donation flow, expanded dashboard
- Crew can mark bookings as free (Gratis checkbox) on staff_user page; free anonymous bookings skip the auto-payment. - Users (and crew) can delete their UserPayments with a confirmation page. - Pay page redesigned around "Eintrittsspende": quick-pick amount buttons (drinks rounded to 5 € + 10/15/20/25/30), "Nur Drinks" escape, intro text, type=text input to dodge browser locale formatting. - Me page: always-visible donate button, intro text linking drinks + Eintrittsspende. - Day cutoff at 06:00 Berlin: festival day rolls at 06:00, applied to current_festival_day plus Frühaufsteher/Nachtschwärmer/Goldene Stunde. - Dashboard: single Finanzen section (income vs costs) with breakdown, fun facts grouped (Trinker / Getränke / Zeit / Geld / Gratis). - Offene Beträge filtered to open > 0. - Staff list shows "(das bist du)" disabled row for own user. - Renamed seed_drinks_2026 -> seed_2026, added Baumarkt 194 € entry. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+227
-22
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.conf import settings
|
||||
@@ -6,7 +7,7 @@ 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, F, IntegerField, Sum, Value, When
|
||||
from django.db.models import Case, Count, F, IntegerField, Sum, Value, When
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
@@ -28,6 +29,12 @@ BOOKING_START = datetime(2026, 5, 15, 0, 0, 0, tzinfo=BERLIN)
|
||||
BOOKING_END = datetime(2026, 5, 31, 23, 59, 59, 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():
|
||||
@@ -54,7 +61,8 @@ def _require_open(request):
|
||||
|
||||
|
||||
def _current_festival_day():
|
||||
weekday = _now().weekday()
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -314,6 +322,31 @@ def delete_consumption_view(request, consumption_id):
|
||||
)
|
||||
|
||||
|
||||
@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):
|
||||
@@ -347,7 +380,33 @@ def pay_view(request):
|
||||
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
|
||||
|
||||
context = _tab_context(request.user)
|
||||
context.update({"phase": phase, "error": error})
|
||||
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)
|
||||
|
||||
|
||||
@@ -468,6 +527,7 @@ def staff_user_view(request, username):
|
||||
"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),
|
||||
}
|
||||
@@ -486,6 +546,7 @@ def staff_book_view(request, username):
|
||||
|
||||
target = _get_staff_target(username)
|
||||
drink_id = request.POST.get("drink_id")
|
||||
for_free = request.POST.get("for_free") == "1"
|
||||
try:
|
||||
drink = Drink.objects.get(pk=int(drink_id))
|
||||
except (Drink.DoesNotExist, TypeError, ValueError):
|
||||
@@ -496,16 +557,17 @@ def staff_book_view(request, username):
|
||||
drink=drink,
|
||||
amount=1,
|
||||
day=_current_festival_day(),
|
||||
for_free=False,
|
||||
for_free=for_free,
|
||||
)
|
||||
if _is_anonymous(target):
|
||||
if _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}",
|
||||
)
|
||||
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}")
|
||||
suffix = "&free=1" if for_free else ""
|
||||
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}{suffix}")
|
||||
|
||||
|
||||
@_staff_required
|
||||
@@ -549,6 +611,32 @@ def staff_pay_view(request, username):
|
||||
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):
|
||||
@@ -597,10 +685,8 @@ def dashboard_view(request):
|
||||
year = current_year()
|
||||
|
||||
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||||
total_payments = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||||
total_balance = total_donations - total_payments
|
||||
total_pct = int(round((total_donations / total_payments) * 100)) if total_payments else 0
|
||||
total_pct_capped = min(total_pct, 100)
|
||||
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 = [
|
||||
@@ -614,9 +700,14 @@ def dashboard_view(request):
|
||||
]
|
||||
sales_revenue = sum((d.sale_price_total for d in drinks), 0)
|
||||
purchase_cost = sum((d.purchase_price_total for d in drinks), 0)
|
||||
drinks_profit = sum((d.balance for d in drinks), 0)
|
||||
refinance_pct = int(round((sales_revenue / purchase_cost) * 100)) if purchase_cost else 0
|
||||
refinance_pct_capped = min(refinance_pct, 100)
|
||||
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():
|
||||
@@ -624,12 +715,15 @@ def dashboard_view(request):
|
||||
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": consumed - paid,
|
||||
"open": open_amount,
|
||||
}
|
||||
)
|
||||
user_rows.sort(key=lambda r: r["open"], reverse=True)
|
||||
@@ -672,24 +766,135 @@ def dashboard_view(request):
|
||||
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}
|
||||
|
||||
# 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_payments": total_payments,
|
||||
"total_balance": total_balance,
|
||||
"total_pct": total_pct,
|
||||
"total_pct_capped": total_pct_capped,
|
||||
"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,
|
||||
"drinks_profit": drinks_profit,
|
||||
"refinance_pct": refinance_pct,
|
||||
"refinance_pct_capped": refinance_pct_capped,
|
||||
"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,
|
||||
"top_free_recipient": top_free_recipient,
|
||||
"free_total_count": free_total_count,
|
||||
}
|
||||
return render(request, "suff/dashboard.html", context)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user