Files
gaehsnitz/gaehsnitz/suff.py
T
flo 2d5ec8fc6d Style suff frontend mobile-first
Dark theme matching GOA palette, standalone microsite (no nav).
- Landing/login: GOA subhead + big "Suff" wordmark, large tap targets
- me page: 2-col 4:3 drink grid, bordered total box, day-grouped
  history with zebra rows, emoji empty-state
- Booking confirmation toast (amber, 5s, then 800ms CSS collapse)
- Touch feedback via :active scale, SVG beer favicon
- no_pin.html link-buttons styled
2026-05-14 12:41:45 +02:00

216 lines
6.5 KiB
Python

from datetime import datetime
from zoneinfo import ZoneInfo
from django.conf import settings
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 F, Sum
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, Drink
User = get_user_model()
BERLIN = ZoneInfo("Europe/Berlin")
BOOKING_START = datetime(2026, 6, 11, 0, 0, 0, tzinfo=BERLIN)
BOOKING_END = datetime(2026, 6, 14, 23, 59, 59, tzinfo=BERLIN)
READONLY_END = datetime(2026, 6, 21, 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"
if now <= READONLY_END:
return "readonly"
return "closed"
def _require_open(request):
"""Raise 404 once tool is fully closed. Return current phase otherwise."""
phase = _phase()
if phase == "closed":
raise Http404
return 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):
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"
]
or 0
)
return {
"tab_user": user,
"consumption_list": consumption,
"total": paid,
}
@require_http_methods(["GET", "POST"])
def name_view(request):
phase = _require_open(request)
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):
phase = _require_open(request)
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):
phase = _require_open(request)
drinks = Drink.objects.order_by("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,
}
)
return render(request, "suff/me.html", context)
@login_required
@require_http_methods(["POST"])
def book_view(request):
phase = _require_open(request)
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}")
@require_http_methods(["POST"])
def logout_view(request):
_require_open(request)
logout(request)
return HttpResponseRedirect(reverse("suff:name"))