Add staff dashboard, payment flow, seed extra 2026 expenses
Self-service /suff/pay/ page lets users record their own payments (cash/PayPal/bank/other) against their tab. Open balance is shown on /suff/me/ alongside total and paid amount, with a Bezahlen button when something is owed. Staff-only /suff/dashboard/ replaces the drink_stats / total_balance / user_stats CLI commands with a mobile-friendly festival view: overall refinancing progress bar (Spenden vs. Ausgaben with Bilanz), drinks refinancing bar (sales revenue vs. purchase cost with profit), per-drink sold/total/balance, open balances per user, and fun facts (top spender, top drink, busiest day, and top user per festival day). Linked from /suff/me/ when the logged-in user is staff. seed_drinks_2026 also creates the non-drink Payments we already know about (toilets, drinks/equipment down payment, band fees per stage day), idempotently keyed on (purpose, date). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+160
-8
@@ -2,6 +2,7 @@ from datetime import datetime
|
||||
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
|
||||
@@ -13,7 +14,7 @@ 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, Drink
|
||||
from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -60,17 +61,24 @@ def _normalize_name(raw):
|
||||
|
||||
|
||||
def _tab_context(user):
|
||||
consumption = user.consumption_list.select_related("drink").order_by("-created_at", "-id")
|
||||
paid = (
|
||||
user.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(total=Sum("cost"))[
|
||||
"total"
|
||||
]
|
||||
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": paid,
|
||||
"total": total,
|
||||
"paid": paid,
|
||||
"open_balance": total - paid,
|
||||
"user_payments": payments,
|
||||
"payment_methods": UserPayment.Method.choices,
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +173,7 @@ def pin_view(request):
|
||||
@require_http_methods(["GET"])
|
||||
def me_view(request):
|
||||
phase = _require_open(request)
|
||||
drinks = Drink.objects.order_by("name") if phase == "booking" else Drink.objects.none()
|
||||
drinks = Drink.objects.filter(year=current_year()).order_by("name") if phase == "booking" else Drink.objects.none()
|
||||
booked_drink = None
|
||||
booked_id = request.GET.get("booked")
|
||||
if booked_id:
|
||||
@@ -180,6 +188,7 @@ def me_view(request):
|
||||
"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)
|
||||
@@ -208,6 +217,149 @@ def book_view(request):
|
||||
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def pay_view(request):
|
||||
phase = _require_open(request)
|
||||
if phase == "before":
|
||||
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=request.user,
|
||||
amount=amount,
|
||||
method=method,
|
||||
note=note,
|
||||
)
|
||||
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
|
||||
|
||||
context = _tab_context(request.user)
|
||||
context.update({"phase": phase, "error": error})
|
||||
return render(request, "suff/pay.html", context)
|
||||
|
||||
|
||||
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_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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
user_rows = []
|
||||
for user in User.objects.all():
|
||||
consumed = user.consumed_drinks_price
|
||||
if not consumed:
|
||||
continue
|
||||
paid = user.paid_amount
|
||||
user_rows.append(
|
||||
{
|
||||
"username": user.username,
|
||||
"consumed": consumed,
|
||||
"paid": paid,
|
||||
"open": consumed - paid,
|
||||
}
|
||||
)
|
||||
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"]})
|
||||
|
||||
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,
|
||||
"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,
|
||||
"user_rows": user_rows,
|
||||
"top_spender": top_spender,
|
||||
"top_drink": top_drink,
|
||||
"busiest_day": busiest_day,
|
||||
"top_per_day": top_per_day,
|
||||
}
|
||||
return render(request, "suff/dashboard.html", context)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def logout_view(request):
|
||||
_require_open(request)
|
||||
|
||||
Reference in New Issue
Block a user