diff --git a/gaehsnitz/management/commands/seed_drinks_2026.py b/gaehsnitz/management/commands/seed_drinks_2026.py new file mode 100644 index 0000000..0b4635d --- /dev/null +++ b/gaehsnitz/management/commands/seed_drinks_2026.py @@ -0,0 +1,64 @@ +from datetime import date + +from django.core.management import BaseCommand +from django.db import transaction + +from gaehsnitz.models import Drink, Payment + +PAYMENTS = [ + # purpose, date, amount + ("Toiletten", date(2026, 5, 6), 210.01), + ("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 5, 18), 400.00), + ("Band: Six Good Years", date(2026, 6, 12), 150.00), + ("Band: Melo-Komplott", date(2026, 6, 12), 100.00), + ("Band: Mörtel", date(2026, 6, 12), 150.00), + ("Band: Kotpiloten", date(2026, 6, 13), 150.00), + ("Band: Knast", date(2026, 6, 13), 150.00), + ("Band: Quast", date(2026, 6, 13), 300.00), +] + +DRINKS = [ + # name, crates, btl/crate, size, price/crate, deposit/crate, sale/btl + ("Sterni", 12, 20, 0.5, 10.99, 3.10, 2.00), + ("Krosti", 5, 20, 0.5, 16.49, 3.10, 2.50), + ("Buddi", 5, 20, 0.5, 20.99, 3.10, 2.50), + ("Helles", 5, 20, 0.5, 15.99, 4.50, 2.50), + ("Radler", 2, 20, 0.5, 14.99, 3.10, 2.50), + ("Lübzer 0,0", 1, 20, 0.5, 17.99, 3.10, 2.50), + ("Freiberger 0,0", 4, 20, 0.5, 15.49, 3.10, 2.50), + ("Mate", 2, 20, 0.5, 17.49, 4.50, 2.50), + ("Vita Cola", 2, 12, 1.0, 10.99, 3.30, 2.50), + ("Spezi", 2, 20, 0.5, 17.99, 3.10, 2.50), + ("Wasser", 10, 12, 1.0, 5.99, 3.30, 1.50), +] + + +class Command(BaseCommand): + help = "Seed Drink rows for the 2026 festival from the supplier invoice." + + @transaction.atomic + def handle(self, *args, **options): + for purpose, day, amount in PAYMENTS: + obj, created = Payment.objects.update_or_create( + purpose=purpose, + date=day, + defaults={"amount": amount}, + ) + self.stdout.write(f"{'created' if created else 'updated'}: {obj.purpose} ({obj.date})") + + for name, crates, btl, size, price, deposit, sale in DRINKS: + obj, created = Drink.objects.update_or_create( + name=name, + year=2026, + defaults={ + "crates_ordered": crates, + "crates_purchased": crates, + "crates_returned": 0, + "purchase_price_per_crate": price, + "deposit_per_crate": deposit, + "bottles_per_crate": btl, + "bottle_size": size, + "sale_price_per_bottle": sale, + }, + ) + self.stdout.write(f"{'created' if created else 'updated'}: {obj.name}") diff --git a/gaehsnitz/static/suff/style.css b/gaehsnitz/static/suff/style.css index fa430d4..b3a9e95 100644 --- a/gaehsnitz/static/suff/style.css +++ b/gaehsnitz/static/suff/style.css @@ -88,7 +88,9 @@ label { font-size: 0.95rem; } -input[type="text"] { +input[type="text"], +input[type="number"], +select { background-color: rgba(80, 40, 10, 0.4); color: #EEEEEE; border: 2px solid #885522; @@ -96,9 +98,24 @@ input[type="text"] { padding: 14px 12px; font-size: 1.1rem; width: 100%; + min-height: 52px; + box-sizing: border-box; + appearance: none; + -webkit-appearance: none; + font-family: inherit; } -input[type="text"]:focus { +select { + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 12px 8px; + padding-right: 40px; +} + +input[type="text"]:focus, +input[type="number"]:focus, +select:focus { outline: none; border-color: #EE9933; } @@ -377,3 +394,49 @@ section { background-color: rgba(80, 40, 10, 0.4); color: #FFCC77; } + +.progress-wrap { + position: relative; + width: 100%; + height: 28px; + background-color: rgba(80, 40, 10, 0.4); + border: 2px solid #885522; + border-radius: 6px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background-color: #EE9933; + box-shadow: 0 0 12px #CC6611; + transition: width 200ms ease; +} + +.progress-label { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + color: #161616; + font-weight: bold; + font-size: 0.95rem; + mix-blend-mode: screen; +} + +.dash-row { + grid-template-columns: 1fr auto auto; +} + +.link-btn-paypal { + background-color: #003087; + color: #FFFFFF; +} + +.link-btn-paypal:hover, .link-btn-paypal:focus { + background-color: #0070BA; + color: #FFFFFF; +} diff --git a/gaehsnitz/suff.py b/gaehsnitz/suff.py index 7fe5a28..5659583 100644 --- a/gaehsnitz/suff.py +++ b/gaehsnitz/suff.py @@ -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) diff --git a/gaehsnitz/suff_urls.py b/gaehsnitz/suff_urls.py index be509ff..1776cc6 100644 --- a/gaehsnitz/suff_urls.py +++ b/gaehsnitz/suff_urls.py @@ -2,9 +2,11 @@ from django.urls import path from gaehsnitz.suff import ( book_view, + dashboard_view, logout_view, me_view, name_view, + pay_view, pin_view, ) @@ -15,5 +17,7 @@ urlpatterns = [ path("pin/", pin_view, name="pin"), path("me/", me_view, name="me"), path("book/", book_view, name="book"), + path("pay/", pay_view, name="pay"), + path("dashboard/", dashboard_view, name="dashboard"), path("logout/", logout_view, name="logout"), ] diff --git a/gaehsnitz/templates/suff/dashboard.html b/gaehsnitz/templates/suff/dashboard.html new file mode 100644 index 0000000..75324f4 --- /dev/null +++ b/gaehsnitz/templates/suff/dashboard.html @@ -0,0 +1,102 @@ +{% extends "suff/base.html" %} +{% load money %} + +{% block content %} +
+ Spenden {{ total_donations|euro }} / Ausgaben {{ total_payments|euro }} +
++ Bilanz: {{ total_balance|euro }} +
++ Verkaufserlös {{ sales_revenue|euro }} / Einkaufspreis {{ purchase_cost|euro }} +
++ Aktueller Gewinn: {{ drinks_profit|euro }} +
+Niemand hat etwas konsumiert.
+ {% endif %} +