400 lines
13 KiB
Python
400 lines
13 KiB
Python
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
|
||
from django.db.models import Case, 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()
|
||
|
||
BERLIN = ZoneInfo("Europe/Berlin")
|
||
# TEST WINDOW (2026-05-15 – 2026-05-31): enabled early for pre-festival testing.
|
||
# Original festival phase: BOOKING_START = 2026-06-11, BOOKING_END = 2026-06-14.
|
||
# Switch back to original dates before the festival.
|
||
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}
|
||
|
||
|
||
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():
|
||
weekday = _now().weekday()
|
||
return DAY_BY_WEEKDAY.get(weekday, 4)
|
||
|
||
|
||
def _normalize_name(raw):
|
||
return slugify(raw or "")[:150]
|
||
|
||
|
||
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": UserPayment.Method.choices,
|
||
}
|
||
|
||
|
||
@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:
|
||
error = "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:
|
||
return render(
|
||
request,
|
||
"suff/no_pin.html",
|
||
{"phase": phase, "username": username},
|
||
)
|
||
|
||
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"):
|
||
return HttpResponseRedirect(reverse("suff:name"))
|
||
|
||
error = None
|
||
if request.method == "POST":
|
||
pin = request.POST.get("pin", "")
|
||
if not (pin.isdigit() and len(pin) == 3):
|
||
error = "PIN muss aus genau 3 Ziffern bestehen."
|
||
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"))
|
||
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"])
|
||
def me_view(request):
|
||
redirect, phase = _require_open(request)
|
||
if redirect:
|
||
return redirect
|
||
drinks = (
|
||
Drink.objects.filter(year=current_year())
|
||
.annotate(
|
||
category_order=Case(
|
||
When(category="beer", then=Value(0)),
|
||
When(category="alc_free_beer", then=Value(1)),
|
||
When(category="radler", then=Value(2)),
|
||
When(category="alc_free_radler", then=Value(3)),
|
||
When(category="soft", then=Value(4)),
|
||
When(category="water", then=Value(5)),
|
||
default=Value(99),
|
||
output_field=IntegerField(),
|
||
)
|
||
)
|
||
.order_by("category_order", "name")
|
||
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
|
||
|
||
drink_id = request.POST.get("drink_id")
|
||
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=False,
|
||
)
|
||
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
|
||
|
||
|
||
@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)
|
||
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):
|
||
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})
|