Compare commits
1 Commits
main
..
e47b03003d
| Author | SHA1 | Date | |
|---|---|---|---|
| e47b03003d |
+17
-185
@@ -3,18 +3,13 @@ from django.contrib import admin, messages
|
|||||||
from django.contrib.admin.utils import unquote
|
from django.contrib.admin.utils import unquote
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
|
|
||||||
admin.site.unregister(Group)
|
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from django.db.models import Sum
|
from gaehsnitz.models import Donation, Payment, Drink, Consumption
|
||||||
|
|
||||||
from gaehsnitz.models import Donation, Payment, Drink, Consumption, UserPayment, current_year
|
|
||||||
from gaehsnitz.templatetags.money import euro
|
from gaehsnitz.templatetags.money import euro
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -25,12 +20,6 @@ class ConsumptionInline(admin.TabularInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
class UserPaymentInline(admin.TabularInline):
|
|
||||||
model = UserPayment
|
|
||||||
extra = 0
|
|
||||||
readonly_fields = ("created_at",)
|
|
||||||
|
|
||||||
|
|
||||||
class SetPinForm(forms.Form):
|
class SetPinForm(forms.Form):
|
||||||
pin = forms.CharField(
|
pin = forms.CharField(
|
||||||
label="Neue PIN (3 Ziffern)",
|
label="Neue PIN (3 Ziffern)",
|
||||||
@@ -47,9 +36,9 @@ class SetPinForm(forms.Form):
|
|||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
list_display = ("username", "is_staff", "is_superuser", "consumed_drinks_price", "paid_amount", "open_balance")
|
list_display = ("username", "consumed_drinks_price")
|
||||||
ordering = ("username",)
|
ordering = ("username",)
|
||||||
list_filter = ["is_staff", "is_superuser"]
|
list_filter = []
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -58,71 +47,17 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
"username",
|
"username",
|
||||||
"password",
|
"password",
|
||||||
"pin_status",
|
"pin_status",
|
||||||
"is_active",
|
|
||||||
"is_staff",
|
|
||||||
"is_superuser",
|
|
||||||
"last_login",
|
|
||||||
"user_permissions",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"BILANZ",
|
|
||||||
{
|
|
||||||
"fields": (
|
|
||||||
"consumed_drinks_price",
|
|
||||||
"paid_amount",
|
|
||||||
"open_balance",
|
|
||||||
"drinks_breakdown",
|
|
||||||
"free_drinks_breakdown",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
("BALANCE", {"fields": ("consumed_drinks_price",)}),
|
||||||
)
|
)
|
||||||
readonly_fields = (
|
readonly_fields = ("consumed_drinks_price", "pin_status")
|
||||||
"consumed_drinks_price",
|
inlines = (ConsumptionInline,)
|
||||||
"paid_amount",
|
|
||||||
"open_balance",
|
|
||||||
"pin_status",
|
|
||||||
"last_login",
|
|
||||||
"drinks_breakdown",
|
|
||||||
"free_drinks_breakdown",
|
|
||||||
)
|
|
||||||
inlines = (UserPaymentInline, ConsumptionInline)
|
|
||||||
|
|
||||||
@admin.display(description="Konsumiert")
|
|
||||||
def consumed_drinks_price(self, user: User):
|
def consumed_drinks_price(self, user: User):
|
||||||
return euro(user.consumed_drinks_price)
|
return euro(user.consumed_drinks_price)
|
||||||
|
|
||||||
@admin.display(description="Bezahlt")
|
|
||||||
def paid_amount(self, user: User):
|
|
||||||
return euro(user.paid_amount)
|
|
||||||
|
|
||||||
@admin.display(description="Offener Betrag")
|
|
||||||
def open_balance(self, user: User):
|
|
||||||
return euro(user.open_balance)
|
|
||||||
|
|
||||||
def _breakdown(self, user: User, for_free: bool):
|
|
||||||
if user.pk is None:
|
|
||||||
return "-"
|
|
||||||
rows = (
|
|
||||||
user.consumption_list.filter(for_free=for_free, drink__year=current_year())
|
|
||||||
.values("drink__name")
|
|
||||||
.annotate(amount=Sum("amount"))
|
|
||||||
.order_by("drink__name")
|
|
||||||
)
|
|
||||||
if not rows:
|
|
||||||
return "-"
|
|
||||||
return ", ".join(f"{r['amount']}x {r['drink__name']}" for r in rows)
|
|
||||||
|
|
||||||
@admin.display(description="Bezahlt")
|
|
||||||
def drinks_breakdown(self, user: User):
|
|
||||||
return self._breakdown(user, for_free=False)
|
|
||||||
|
|
||||||
@admin.display(description="Gratis")
|
|
||||||
def free_drinks_breakdown(self, user: User):
|
|
||||||
return self._breakdown(user, for_free=True)
|
|
||||||
|
|
||||||
@admin.display(description="PIN")
|
@admin.display(description="PIN")
|
||||||
def pin_status(self, user: User):
|
def pin_status(self, user: User):
|
||||||
status = "gesetzt" if user.pin else "nicht gesetzt"
|
status = "gesetzt" if user.pin else "nicht gesetzt"
|
||||||
@@ -169,39 +104,10 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context)
|
return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context)
|
||||||
|
|
||||||
|
|
||||||
class YearFilter(admin.SimpleListFilter):
|
|
||||||
title = "Jahr"
|
|
||||||
parameter_name = "year"
|
|
||||||
field_name = "created_at"
|
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
|
||||||
years = model_admin.model.objects.dates(self.field_name, "year", order="DESC")
|
|
||||||
return [(y.year, str(y.year)) for y in years]
|
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
|
||||||
if self.value():
|
|
||||||
return queryset.filter(**{f"{self.field_name}__year": self.value()})
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(UserPayment)
|
|
||||||
class UserPaymentAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("created_at", "user", "amount", "method", "note")
|
|
||||||
list_filter = ("method", YearFilter)
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
search_fields = ("user__username", "note")
|
|
||||||
|
|
||||||
@admin.display(ordering="amount")
|
|
||||||
def amount(self, payment: UserPayment):
|
|
||||||
return euro(payment.amount)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Donation)
|
@admin.register(Donation)
|
||||||
class DonationAdmin(admin.ModelAdmin):
|
class DonationAdmin(admin.ModelAdmin):
|
||||||
list_display = ("date", "amount", "note")
|
list_display = ("date", "amount", "note")
|
||||||
list_filter = ("date",)
|
|
||||||
ordering = ("-date",)
|
ordering = ("-date",)
|
||||||
search_fields = ("note",)
|
|
||||||
|
|
||||||
@admin.display(ordering="amount")
|
@admin.display(ordering="amount")
|
||||||
def amount(self, donation: Donation):
|
def amount(self, donation: Donation):
|
||||||
@@ -210,10 +116,8 @@ class DonationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Payment)
|
@admin.register(Payment)
|
||||||
class PaymentAdmin(admin.ModelAdmin):
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
list_display = ("date", "purpose", "amount", "method")
|
list_display = ("date", "purpose", "amount")
|
||||||
list_filter = ("method", "date")
|
|
||||||
ordering = ("-date",)
|
ordering = ("-date",)
|
||||||
search_fields = ("purpose",)
|
|
||||||
|
|
||||||
@admin.display(ordering="amount")
|
@admin.display(ordering="amount")
|
||||||
def amount(self, payment: Payment):
|
def amount(self, payment: Payment):
|
||||||
@@ -222,46 +126,31 @@ class PaymentAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Drink)
|
@admin.register(Drink)
|
||||||
class DrinkAdmin(admin.ModelAdmin):
|
class DrinkAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = ("name", "purchase_price_per_crate", "crates_purchased", "purchase_price_total")
|
||||||
"name",
|
|
||||||
"year",
|
|
||||||
"crates_purchased",
|
|
||||||
"bottles_sold",
|
|
||||||
"bottles_remaining",
|
|
||||||
"purchase_price_total",
|
|
||||||
"balance",
|
|
||||||
)
|
|
||||||
list_filter = ("year",)
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "year")}),
|
(None, {"fields": ("name",)}),
|
||||||
(
|
(
|
||||||
"Kästen",
|
"crates",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"crates_ordered",
|
"crates_ordered",
|
||||||
"crates_purchased",
|
"crates_purchased",
|
||||||
"crates_full_returned",
|
|
||||||
"crates_returned",
|
"crates_returned",
|
||||||
"crates_remaining",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Flaschen",
|
"bottles",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"bottles_per_crate",
|
"bottles_per_crate",
|
||||||
"bottles_total",
|
"bottles_total",
|
||||||
"bottles_returned",
|
"bottles_returned",
|
||||||
"bottles_sold",
|
|
||||||
"bottles_given_away",
|
|
||||||
"bottles_consumed",
|
|
||||||
"bottles_remaining",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Menge",
|
"amount",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"bottle_size",
|
"bottle_size",
|
||||||
@@ -271,18 +160,17 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Einkauf",
|
"purchase",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"purchase_price_per_crate",
|
"purchase_price_per_crate",
|
||||||
"purchase_price_per_bottle",
|
"purchase_price_per_bottle",
|
||||||
"purchase_price_total",
|
"purchase_price_total",
|
||||||
"remaining_purchase_value",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Pfand",
|
"deposit",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"deposit_per_crate",
|
"deposit_per_crate",
|
||||||
@@ -293,12 +181,14 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Verkauf",
|
"sales",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"sale_price_per_bottle",
|
"sale_price_per_bottle",
|
||||||
|
"bottles_sold",
|
||||||
"sales_purchase_value",
|
"sales_purchase_value",
|
||||||
"sale_price_total",
|
"sale_price_total",
|
||||||
|
"bottles_given_away",
|
||||||
"giveaway_purchase_value",
|
"giveaway_purchase_value",
|
||||||
"balance",
|
"balance",
|
||||||
)
|
)
|
||||||
@@ -308,15 +198,10 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"bottles_total",
|
"bottles_total",
|
||||||
"bottles_returned",
|
"bottles_returned",
|
||||||
"bottles_consumed",
|
|
||||||
"bottles_remaining",
|
|
||||||
"crates_full_returned",
|
|
||||||
"crates_remaining",
|
|
||||||
"amount_per_crate",
|
"amount_per_crate",
|
||||||
"amount_total",
|
"amount_total",
|
||||||
"purchase_price_per_bottle",
|
"purchase_price_per_bottle",
|
||||||
"purchase_price_total",
|
"purchase_price_total",
|
||||||
"remaining_purchase_value",
|
|
||||||
"deposit_total",
|
"deposit_total",
|
||||||
"deposit_refund",
|
"deposit_refund",
|
||||||
"deposit_kept",
|
"deposit_kept",
|
||||||
@@ -328,82 +213,29 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
"balance",
|
"balance",
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Kästen voll zurück")
|
|
||||||
def crates_full_returned(self, drink: Drink):
|
|
||||||
return drink.crates_full_returned
|
|
||||||
|
|
||||||
@admin.display(description="Kästen übrig")
|
|
||||||
def crates_remaining(self, drink: Drink):
|
|
||||||
return drink.crates_remaining
|
|
||||||
|
|
||||||
@admin.display(description="Flaschen gesamt")
|
|
||||||
def bottles_total(self, drink: Drink):
|
|
||||||
return drink.bottles_total
|
|
||||||
|
|
||||||
@admin.display(description="Flaschen leer zurück")
|
|
||||||
def bottles_returned(self, drink: Drink):
|
|
||||||
return drink.bottles_returned
|
|
||||||
|
|
||||||
@admin.display(description="Flaschen verkauft")
|
|
||||||
def bottles_sold(self, drink: Drink):
|
|
||||||
return drink.bottles_sold
|
|
||||||
|
|
||||||
@admin.display(description="Flaschen verschenkt")
|
|
||||||
def bottles_given_away(self, drink: Drink):
|
|
||||||
return drink.bottles_given_away
|
|
||||||
|
|
||||||
@admin.display(description="Flaschen konsumiert")
|
|
||||||
def bottles_consumed(self, drink: Drink):
|
|
||||||
return drink.bottles_consumed
|
|
||||||
|
|
||||||
@admin.display(description="Flaschen übrig")
|
|
||||||
def bottles_remaining(self, drink: Drink):
|
|
||||||
return drink.bottles_remaining
|
|
||||||
|
|
||||||
@admin.display(description="Menge pro Kasten (l)")
|
|
||||||
def amount_per_crate(self, drink: Drink):
|
|
||||||
return drink.amount_per_crate
|
|
||||||
|
|
||||||
@admin.display(description="Menge gesamt (l)")
|
|
||||||
def amount_total(self, drink: Drink):
|
|
||||||
return drink.amount_total
|
|
||||||
|
|
||||||
@admin.display(description="Einkaufspreis pro Flasche")
|
|
||||||
def purchase_price_per_bottle(self, drink: Drink):
|
def purchase_price_per_bottle(self, drink: Drink):
|
||||||
return euro(drink.purchase_price_per_bottle)
|
return euro(drink.purchase_price_per_bottle)
|
||||||
|
|
||||||
@admin.display(description="Einkaufspreis gesamt")
|
|
||||||
def purchase_price_total(self, drink: Drink):
|
def purchase_price_total(self, drink: Drink):
|
||||||
return euro(drink.purchase_price_total)
|
return euro(drink.purchase_price_total)
|
||||||
|
|
||||||
@admin.display(description="Einkaufswert übrig")
|
|
||||||
def remaining_purchase_value(self, drink: Drink):
|
|
||||||
return euro(drink.remaining_purchase_value)
|
|
||||||
|
|
||||||
@admin.display(description="Pfand gesamt")
|
|
||||||
def deposit_total(self, drink: Drink):
|
def deposit_total(self, drink: Drink):
|
||||||
return euro(drink.deposit_total)
|
return euro(drink.deposit_total)
|
||||||
|
|
||||||
@admin.display(description="Pfand zurück")
|
|
||||||
def deposit_refund(self, drink: Drink):
|
def deposit_refund(self, drink: Drink):
|
||||||
return euro(drink.deposit_refund)
|
return euro(drink.deposit_refund)
|
||||||
|
|
||||||
@admin.display(description="Pfand einbehalten")
|
|
||||||
def deposit_kept(self, drink: Drink):
|
def deposit_kept(self, drink: Drink):
|
||||||
return euro(drink.deposit_kept)
|
return euro(drink.deposit_kept)
|
||||||
|
|
||||||
@admin.display(description="Einkaufswert verkauft")
|
|
||||||
def sales_purchase_value(self, drink: Drink):
|
def sales_purchase_value(self, drink: Drink):
|
||||||
return euro(drink.sales_purchase_value)
|
return euro(drink.sales_purchase_value)
|
||||||
|
|
||||||
@admin.display(description="Verkaufserlös")
|
|
||||||
def sale_price_total(self, drink: Drink):
|
def sale_price_total(self, drink: Drink):
|
||||||
return euro(drink.sale_price_total)
|
return euro(drink.sale_price_total)
|
||||||
|
|
||||||
@admin.display(description="Einkaufswert verschenkt")
|
|
||||||
def giveaway_purchase_value(self, drink: Drink):
|
def giveaway_purchase_value(self, drink: Drink):
|
||||||
return euro(drink.giveaway_purchase_value)
|
return euro(drink.giveaway_purchase_value)
|
||||||
|
|
||||||
@admin.display(description="Bilanz")
|
|
||||||
def balance(self, drink: Drink):
|
def balance(self, drink: Drink):
|
||||||
return mark_safe(f"<b>{euro(drink.balance)}</b>")
|
return mark_safe(f"<b>{euro(drink.balance)}</b>")
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from django.core.management import BaseCommand
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from gaehsnitz.models import Drink
|
||||||
|
from gaehsnitz.templatetags.money import euro
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
for drink in Drink.objects.all():
|
||||||
|
print(f"--- {drink.name} ---")
|
||||||
|
|
||||||
|
print(f"Kästen (laut Abrechnung):")
|
||||||
|
ordered = drink.crates_ordered
|
||||||
|
print(f" bestellt: {ordered}")
|
||||||
|
purchased = drink.crates_purchased
|
||||||
|
print(f" gekauft: {purchased}")
|
||||||
|
full_ret = ordered - purchased
|
||||||
|
print(f" voll zurück: {full_ret}")
|
||||||
|
empty_ret = drink.crates_returned
|
||||||
|
print(f" leer zurück: {empty_ret}")
|
||||||
|
remaining = purchased - empty_ret
|
||||||
|
print(f" übrig: {remaining}")
|
||||||
|
|
||||||
|
print("Flaschen (laut Strichliste):")
|
||||||
|
bought = drink.bottles_total
|
||||||
|
print(f" gekauft: {bought}")
|
||||||
|
consumed = drink.consumption_list.aggregate(sum=Sum("amount"))["sum"] or 0
|
||||||
|
print(f" getrunken: {consumed}")
|
||||||
|
remaining = bought - consumed
|
||||||
|
print(f" übrig: {remaining}")
|
||||||
|
purchase_value = remaining * drink.purchase_price_per_bottle
|
||||||
|
print(f" Wert: {euro(purchase_value)}")
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.db.models import F, Sum
|
|
||||||
|
|
||||||
from gaehsnitz.models import Consumption, Payment, UserPayment
|
|
||||||
|
|
||||||
|
|
||||||
YEAR = 2026
|
|
||||||
CASH_PREFILL = Decimal("500.00")
|
|
||||||
|
|
||||||
|
|
||||||
def eur(amount):
|
|
||||||
return str(Decimal(amount).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Print full 2026 festival finance summary"
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
w = self.stdout.write
|
|
||||||
sep = "-" * 40
|
|
||||||
|
|
||||||
# --- Ausgaben ---
|
|
||||||
w("\nAUSGABEN")
|
|
||||||
w(sep)
|
|
||||||
payments = Payment.objects.filter(date__year=YEAR).order_by("date")
|
|
||||||
total_out = Decimal("0")
|
|
||||||
for p in payments:
|
|
||||||
method_label = dict(Payment.Method.choices).get(p.method, p.method)
|
|
||||||
w(f" {p.date} {eur(p.amount):>6}€ {method_label:<15} {p.purpose}")
|
|
||||||
total_out += p.amount
|
|
||||||
w(sep)
|
|
||||||
w(f" {'TOTAL':<11} {eur(total_out):>6}€")
|
|
||||||
|
|
||||||
# by method
|
|
||||||
w("")
|
|
||||||
for method, label in Payment.Method.choices:
|
|
||||||
subtotal = payments.filter(method=method).aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
|
||||||
if subtotal:
|
|
||||||
w(f" {label:<15} {eur(subtotal):>6}€")
|
|
||||||
|
|
||||||
# --- Einnahmen ---
|
|
||||||
w("\nEINNAHMEN")
|
|
||||||
w(sep)
|
|
||||||
up = UserPayment.objects.filter(created_at__year=YEAR)
|
|
||||||
|
|
||||||
cash_prefill = CASH_PREFILL
|
|
||||||
cash_payments = up.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
|
||||||
cash_from_sales = cash_payments - cash_prefill
|
|
||||||
|
|
||||||
non_cash = up.exclude(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
|
||||||
|
|
||||||
total_in = cash_from_sales + non_cash
|
|
||||||
|
|
||||||
drink_revenue = Consumption.objects.filter(for_free=False, drink__year=YEAR).annotate(
|
|
||||||
cost=F("amount") * F("drink__sale_price_per_bottle")
|
|
||||||
).aggregate(s=Sum("cost"))["s"] or Decimal("0")
|
|
||||||
entry_donations = total_in - drink_revenue
|
|
||||||
|
|
||||||
w(f" Cash: {eur(cash_from_sales):>6}€")
|
|
||||||
w(f" Cashless: {eur(non_cash):>6}€")
|
|
||||||
w(f" Einnahmen Gesamt: {eur(total_in):>6}€")
|
|
||||||
w(f" - Getränke-Umsatz: {eur(drink_revenue):>6}€")
|
|
||||||
w(f" - Eintrittsspenden: {eur(entry_donations):>6}€")
|
|
||||||
|
|
||||||
# --- Kassensaldo ---
|
|
||||||
w("\nKASSE")
|
|
||||||
w(sep)
|
|
||||||
cash_out = payments.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
|
||||||
expected_cash = cash_prefill + cash_from_sales - cash_out
|
|
||||||
w(f" Kassen-Vorschuss: {eur(cash_prefill):>6}€")
|
|
||||||
w(f" Cash-Einnahmen: {eur(cash_from_sales):>6}€")
|
|
||||||
w(f" Cash-Ausgaben: {eur(cash_out):>6}€")
|
|
||||||
w(f" -> in Kasse: {eur(expected_cash):>6}€")
|
|
||||||
|
|
||||||
# --- Gesamtbilanz ---
|
|
||||||
w("\nGESAMTBILANZ")
|
|
||||||
w(sep)
|
|
||||||
net = total_in - total_out
|
|
||||||
out_of_pocket = total_out - total_in
|
|
||||||
w(f" Gesamtausgaben: {eur(total_out):>6}€")
|
|
||||||
w(f" Gesamteinnahmen: {eur(total_in):>6}€")
|
|
||||||
w(sep)
|
|
||||||
label = "Verlust" if out_of_pocket > 0 else "Gewinn"
|
|
||||||
w(f" {label}: {eur(abs(net)):>6}€\n")
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from gaehsnitz.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Merge source user into target user, then delete source"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("source", help="Username of user to merge FROM (will be deleted)")
|
|
||||||
parser.add_argument("target", help="Username of user to merge INTO (will be kept)")
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
source_name = options["source"]
|
|
||||||
target_name = options["target"]
|
|
||||||
|
|
||||||
if source_name == target_name:
|
|
||||||
raise CommandError("Source and target must be different users")
|
|
||||||
|
|
||||||
try:
|
|
||||||
source = User.objects.get(username=source_name)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise CommandError(f"Source user '{source_name}' not found")
|
|
||||||
|
|
||||||
try:
|
|
||||||
target = User.objects.get(username=target_name)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise CommandError(f"Target user '{target_name}' not found")
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
payments = source.user_payments.count()
|
|
||||||
consumptions = source.consumption_list.count()
|
|
||||||
|
|
||||||
source.user_payments.update(user=target)
|
|
||||||
source.consumption_list.update(user=target)
|
|
||||||
|
|
||||||
source.delete()
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f"Merged '{source_name}' into '{target_name}': "
|
|
||||||
f"{payments} payment(s), {consumptions} consumption(s) reassigned. "
|
|
||||||
f"Source user deleted."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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, 6, 8), 400.00),
|
|
||||||
("Baumarkt", date(2026, 5, 23), 194.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, category, crates, btl/crate, size, price/crate, deposit/crate, sale/btl
|
|
||||||
("Sternburg Export", "beer", 12, 20, 0.5, 9.49, 3.10, 2.00),
|
|
||||||
("Ur-Krostitzer", "beer", 5, 20, 0.5, 16.49, 3.10, 2.50),
|
|
||||||
("Budweiser", "beer", 5, 20, 0.5, 20.99, 3.10, 2.50),
|
|
||||||
("Altenburger Helles", "beer", 5, 20, 0.5, 13.99, 4.50, 2.50),
|
|
||||||
("Feldschl. Radler", "radler", 2, 20, 0.5, 14.99, 3.10, 2.50),
|
|
||||||
("Lübzer Grapef. 0,0", "alc_free_radler", 1, 20, 0.5, 17.99, 3.10, 2.50),
|
|
||||||
("Freiberger 0,0", "alc_free_beer", 4, 20, 0.5, 15.49, 3.10, 2.50),
|
|
||||||
("Club Mate", "soft", 2, 20, 0.5, 17.49, 4.50, 2.50),
|
|
||||||
("Vita Cola", "soft", 2, 12, 1.0, 10.99, 3.30, 2.50),
|
|
||||||
("Paulaner Spezi", "soft", 2, 20, 0.5, 17.99, 3.10, 2.50),
|
|
||||||
("Wasser", "water", 10, 12, 1.0, 5.99, 3.30, 1.50),
|
|
||||||
("Sekty Drink", "sekt", 0, 1, 0.33, 0.00, 0.00, 3.50),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Seed Drink and Payment rows for the 2026 festival."
|
|
||||||
|
|
||||||
@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, category, crates, btl, size, price, deposit, sale in DRINKS:
|
|
||||||
obj, created = Drink.objects.update_or_create(
|
|
||||||
name=name,
|
|
||||||
year=2026,
|
|
||||||
defaults={
|
|
||||||
"category": category,
|
|
||||||
"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}")
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from gaehsnitz.models import Payment, Donation
|
||||||
|
from gaehsnitz.templatetags.money import euro
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
all_donations_sum = Donation.objects.all().aggregate(sum=Sum("amount"))["sum"]
|
||||||
|
print(f"Alle Spenden/Zahlungen: {euro(all_donations_sum)}")
|
||||||
|
all_payments_sum = Payment.objects.all().aggregate(sum=Sum("amount"))["sum"]
|
||||||
|
print(f"Alle Ausgaben: {euro(all_payments_sum)}")
|
||||||
|
balance = all_donations_sum - all_payments_sum
|
||||||
|
print("-------------------------")
|
||||||
|
print(f"Bilanz: {euro(balance)}")
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from gaehsnitz.models import Drink
|
|
||||||
|
|
||||||
# Actual crates bought from supplier (all others were returned full, no cost)
|
|
||||||
# (name, crates_ordered, crates_purchased)
|
|
||||||
# crates_returned = crates_purchased (all empties back, full deposit refunded)
|
|
||||||
ACTUAL_CRATES = [
|
|
||||||
("Sternburg Export", 12, 11),
|
|
||||||
("Ur-Krostitzer", 5, 2),
|
|
||||||
("Budweiser", 5, 2),
|
|
||||||
("Altenburger Helles", 5, 5),
|
|
||||||
("Feldschl. Radler", 2, 1),
|
|
||||||
("Lübzer Grapef. 0,0", 1, 1),
|
|
||||||
("Freiberger 0,0", 4, 3),
|
|
||||||
("Club Mate", 2, 2),
|
|
||||||
("Vita Cola", 2, 1),
|
|
||||||
("Paulaner Spezi", 2, 2),
|
|
||||||
("Wasser", 10, 5),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Update 2026 drink crates to actual purchased amounts"
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
with transaction.atomic():
|
|
||||||
for name, ordered, purchased in ACTUAL_CRATES:
|
|
||||||
updated = Drink.objects.filter(name=name, year=2026).update(
|
|
||||||
crates_ordered=ordered,
|
|
||||||
crates_purchased=purchased,
|
|
||||||
crates_returned=purchased,
|
|
||||||
)
|
|
||||||
if updated:
|
|
||||||
self.stdout.write(f" {name}: ordered={ordered}, purchased={purchased}, returned={purchased}")
|
|
||||||
else:
|
|
||||||
self.stdout.write(self.style.WARNING(f" NOT FOUND: {name}"))
|
|
||||||
self.stdout.write(self.style.SUCCESS("Done."))
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from gaehsnitz.models import Drink
|
||||||
|
from gaehsnitz.templatetags.money import euro
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
id_to_name = {d.id: d.name for d in Drink.objects.all()}
|
||||||
|
for user in User.objects.all().order_by("username"):
|
||||||
|
to_pay = user.consumed_drinks_price
|
||||||
|
if to_pay != 0:
|
||||||
|
paid_consumption = user.consumption_list.filter(for_free=False)
|
||||||
|
drink_list = []
|
||||||
|
for drink_dict in paid_consumption.values("drink_id").annotate(amount=Sum("amount")):
|
||||||
|
name = id_to_name[drink_dict["drink_id"]]
|
||||||
|
amount = drink_dict["amount"]
|
||||||
|
drink_list.append(f"{amount}x {name}")
|
||||||
|
print(f"{user.username.capitalize()}: {euro(to_pay)} ({', '.join(drink_list)})")
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-05-14 18:54
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("gaehsnitz", "0003_consumption_created_at_user_pin"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="drink",
|
|
||||||
name="year",
|
|
||||||
field=models.PositiveSmallIntegerField(default=2024),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="name",
|
|
||||||
field=models.CharField(max_length=32),
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name="drink",
|
|
||||||
unique_together={("name", "year")},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
-167
@@ -1,167 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-05-14 19:15
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import gaehsnitz.models
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("gaehsnitz", "0004_drink_year"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="consumption",
|
|
||||||
options={"verbose_name": "Konsum", "verbose_name_plural": "Konsum"},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="donation",
|
|
||||||
options={"verbose_name": "Spende", "verbose_name_plural": "Spenden"},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="drink",
|
|
||||||
options={"verbose_name": "Getränk", "verbose_name_plural": "Getränke"},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="payment",
|
|
||||||
options={"verbose_name": "Ausgabe", "verbose_name_plural": "Ausgaben"},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="user",
|
|
||||||
options={"verbose_name": "Benutzer", "verbose_name_plural": "Benutzer"},
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="consumption",
|
|
||||||
name="amount",
|
|
||||||
field=models.PositiveSmallIntegerField(verbose_name="Anzahl"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="consumption",
|
|
||||||
name="created_at",
|
|
||||||
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name="Gebucht am"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="consumption",
|
|
||||||
name="day",
|
|
||||||
field=models.PositiveSmallIntegerField(
|
|
||||||
choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")], verbose_name="Tag"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="consumption",
|
|
||||||
name="drink",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="consumption_list",
|
|
||||||
related_query_name="consumption",
|
|
||||||
to="gaehsnitz.drink",
|
|
||||||
verbose_name="Getränk",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="consumption",
|
|
||||||
name="for_free",
|
|
||||||
field=models.BooleanField(default=False, verbose_name="Gratis"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="consumption",
|
|
||||||
name="user",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="consumption_list",
|
|
||||||
related_query_name="consumption",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
verbose_name="Benutzer",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="donation",
|
|
||||||
name="amount",
|
|
||||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="donation",
|
|
||||||
name="date",
|
|
||||||
field=models.DateField(verbose_name="Datum"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="donation",
|
|
||||||
name="note",
|
|
||||||
field=models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="bottle_size",
|
|
||||||
field=models.FloatField(verbose_name="Flaschengröße (l)"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="bottles_per_crate",
|
|
||||||
field=models.PositiveSmallIntegerField(verbose_name="Flaschen pro Kasten"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="crates_ordered",
|
|
||||||
field=models.PositiveSmallIntegerField(
|
|
||||||
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
|
|
||||||
verbose_name="Kästen bestellt",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="crates_purchased",
|
|
||||||
field=models.PositiveSmallIntegerField(verbose_name="Kästen gekauft"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="crates_returned",
|
|
||||||
field=models.PositiveSmallIntegerField(verbose_name="Kästen leer zurück"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="deposit_per_crate",
|
|
||||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Pfand pro Kasten"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="name",
|
|
||||||
field=models.CharField(max_length=32, verbose_name="Name"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="purchase_price_per_crate",
|
|
||||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Einkaufspreis pro Kasten"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="sale_price_per_bottle",
|
|
||||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Verkaufspreis pro Flasche"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="year",
|
|
||||||
field=models.PositiveSmallIntegerField(default=2024, verbose_name="Jahr"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="payment",
|
|
||||||
name="amount",
|
|
||||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="payment",
|
|
||||||
name="date",
|
|
||||||
field=models.DateField(verbose_name="Datum"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="payment",
|
|
||||||
name="purpose",
|
|
||||||
field=models.CharField(max_length=64, verbose_name="Zweck"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="user",
|
|
||||||
name="pin",
|
|
||||||
field=models.CharField(blank=True, default="", max_length=128, verbose_name="PIN"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-05-14 19:37
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import gaehsnitz.models
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("gaehsnitz", "0005_alter_consumption_options_alter_donation_options_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="UserPayment",
|
|
||||||
fields=[
|
|
||||||
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag")),
|
|
||||||
(
|
|
||||||
"method",
|
|
||||||
models.CharField(
|
|
||||||
choices=[
|
|
||||||
("cash", "Bar"),
|
|
||||||
("paypal", "PayPal"),
|
|
||||||
("bank", "Überweisung"),
|
|
||||||
("other", "Sonstiges"),
|
|
||||||
],
|
|
||||||
max_length=16,
|
|
||||||
verbose_name="Methode",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("note", models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz")),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Bezahlt am")),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="user_payments",
|
|
||||||
related_query_name="user_payment",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
verbose_name="Benutzer",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Zahlung",
|
|
||||||
"verbose_name_plural": "Zahlungen",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-05-14 20:35
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("gaehsnitz", "0006_user_payment"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="drink",
|
|
||||||
name="category",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[
|
|
||||||
("beer", "Bier"),
|
|
||||||
("radler", "Radler"),
|
|
||||||
("alc_free_beer", "Bier alkoholfrei"),
|
|
||||||
("soft", "Softdrink"),
|
|
||||||
("water", "Wasser"),
|
|
||||||
],
|
|
||||||
default="beer",
|
|
||||||
max_length=16,
|
|
||||||
verbose_name="Kategorie",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-05-14 20:48
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("gaehsnitz", "0007_drink_category"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="drink",
|
|
||||||
name="category",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[
|
|
||||||
("beer", "Bier"),
|
|
||||||
("radler", "Radler"),
|
|
||||||
("alc_free_beer", "Bier alkoholfrei"),
|
|
||||||
("alc_free_radler", "Radler alkoholfrei"),
|
|
||||||
("soft", "Softdrink"),
|
|
||||||
("water", "Wasser"),
|
|
||||||
],
|
|
||||||
default="beer",
|
|
||||||
max_length=16,
|
|
||||||
verbose_name="Kategorie",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
ANONYMOUS_USERNAME = "anonym"
|
|
||||||
|
|
||||||
|
|
||||||
def create_anonymous(apps, schema_editor):
|
|
||||||
User = apps.get_model("gaehsnitz", "User")
|
|
||||||
User.objects.get_or_create(
|
|
||||||
username=ANONYMOUS_USERNAME,
|
|
||||||
defaults={"pin": "", "is_staff": False, "is_superuser": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_anonymous(apps, schema_editor):
|
|
||||||
User = apps.get_model("gaehsnitz", "User")
|
|
||||||
User.objects.filter(username=ANONYMOUS_USERNAME).delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("gaehsnitz", "0008_alter_drink_category"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(create_anonymous, delete_anonymous),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-06-09 13:42
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('gaehsnitz', '0009_anonymous_user'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='drink',
|
|
||||||
name='category',
|
|
||||||
field=models.CharField(choices=[('beer', 'Bier'), ('radler', 'Radler'), ('sekt', 'Sekt'), ('alc_free_beer', 'Bier alkoholfrei'), ('alc_free_radler', 'Radler alkoholfrei'), ('soft', 'Softdrink'), ('water', 'Wasser')], default='beer', max_length=16, verbose_name='Kategorie'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.6 on 2026-06-20 12:06
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('gaehsnitz', '0010_add_sekt_category'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='payment',
|
|
||||||
name='method',
|
|
||||||
field=models.CharField(choices=[('cash', 'Bar'), ('card', 'EC-Karte'), ('paypal', 'PayPal'), ('bank', 'Überweisung')], default='paypal', max_length=16, verbose_name='Methode'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+25
-138
@@ -2,11 +2,6 @@ from django.contrib.auth.hashers import check_password, make_password
|
|||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Sum, F
|
from django.db.models import Sum, F
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
def current_year():
|
|
||||||
return timezone.now().year
|
|
||||||
|
|
||||||
|
|
||||||
class PriceField(models.DecimalField):
|
class PriceField(models.DecimalField):
|
||||||
@@ -16,11 +11,7 @@ class PriceField(models.DecimalField):
|
|||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
pin = models.CharField("PIN", max_length=128, blank=True, default="")
|
pin = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Benutzer"
|
|
||||||
verbose_name_plural = "Benutzer"
|
|
||||||
|
|
||||||
def set_pin(self, raw_pin):
|
def set_pin(self, raw_pin):
|
||||||
self.pin = make_password(raw_pin)
|
self.pin = make_password(raw_pin)
|
||||||
@@ -40,31 +31,16 @@ class User(AbstractUser):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def consumed_drinks_price(self):
|
def consumed_drinks_price(self):
|
||||||
query = (
|
query = self.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(
|
||||||
self.paid_drinks.filter(drink__year=current_year())
|
sum=Sum("cost")
|
||||||
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
|
|
||||||
.aggregate(sum=Sum("cost"))
|
|
||||||
)
|
)
|
||||||
return query["sum"] or 0
|
return query["sum"] or 0
|
||||||
|
|
||||||
@property
|
|
||||||
def paid_amount(self):
|
|
||||||
query = self.user_payments.filter(created_at__year=current_year()).aggregate(sum=Sum("amount"))
|
|
||||||
return query["sum"] or 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def open_balance(self):
|
|
||||||
return self.consumed_drinks_price - self.paid_amount
|
|
||||||
|
|
||||||
|
|
||||||
class Donation(models.Model):
|
class Donation(models.Model):
|
||||||
date = models.DateField("Datum")
|
date = models.DateField()
|
||||||
amount = PriceField("Betrag")
|
amount = PriceField()
|
||||||
note = models.CharField("Notiz", max_length=64, blank=True, default="")
|
note = models.CharField(max_length=64, blank=True, default="")
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Spende"
|
|
||||||
verbose_name_plural = "Spenden"
|
|
||||||
|
|
||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
@@ -73,59 +49,26 @@ class Payment(models.Model):
|
|||||||
bands = 11, "Bands"
|
bands = 11, "Bands"
|
||||||
supply_purchase = 12, "Getränke-/Essenseinkauf"
|
supply_purchase = 12, "Getränke-/Essenseinkauf"
|
||||||
|
|
||||||
class Method(models.TextChoices):
|
purpose = models.CharField(max_length=64)
|
||||||
cash = "cash", "Bar"
|
date = models.DateField()
|
||||||
card = "card", "EC-Karte"
|
amount = PriceField()
|
||||||
paypal = "paypal", "PayPal"
|
|
||||||
bank = "bank", "Überweisung"
|
|
||||||
|
|
||||||
purpose = models.CharField("Zweck", max_length=64)
|
|
||||||
date = models.DateField("Datum")
|
|
||||||
amount = PriceField("Betrag")
|
|
||||||
method = models.CharField("Methode", max_length=16, choices=Method.choices, default=Method.paypal)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Ausgabe"
|
|
||||||
verbose_name_plural = "Ausgaben"
|
|
||||||
|
|
||||||
|
|
||||||
class Drink(models.Model):
|
class Drink(models.Model):
|
||||||
class Category(models.TextChoices):
|
name = models.CharField(max_length=32, unique=True)
|
||||||
beer = "beer", "Bier"
|
|
||||||
radler = "radler", "Radler"
|
|
||||||
sekt = "sekt", "Sekt"
|
|
||||||
alc_free_beer = "alc_free_beer", "Bier alkoholfrei"
|
|
||||||
alc_free_radler = "alc_free_radler", "Radler alkoholfrei"
|
|
||||||
soft = "soft", "Softdrink"
|
|
||||||
water = "water", "Wasser"
|
|
||||||
|
|
||||||
name = models.CharField("Name", max_length=32)
|
|
||||||
year = models.PositiveSmallIntegerField("Jahr", default=2024)
|
|
||||||
category = models.CharField(
|
|
||||||
"Kategorie",
|
|
||||||
max_length=16,
|
|
||||||
choices=Category.choices,
|
|
||||||
default=Category.beer,
|
|
||||||
)
|
|
||||||
crates_ordered = models.PositiveSmallIntegerField(
|
crates_ordered = models.PositiveSmallIntegerField(
|
||||||
"Kästen bestellt",
|
help_text="just informational to see how good we planned, not the actual consumed/paid drinks"
|
||||||
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
|
|
||||||
)
|
)
|
||||||
crates_purchased = models.PositiveSmallIntegerField("Kästen gekauft")
|
crates_purchased = models.PositiveSmallIntegerField()
|
||||||
crates_returned = models.PositiveSmallIntegerField("Kästen leer zurück")
|
crates_returned = models.PositiveSmallIntegerField()
|
||||||
purchase_price_per_crate = PriceField("Einkaufspreis pro Kasten")
|
purchase_price_per_crate = PriceField()
|
||||||
deposit_per_crate = PriceField("Pfand pro Kasten")
|
deposit_per_crate = PriceField()
|
||||||
bottles_per_crate = models.PositiveSmallIntegerField("Flaschen pro Kasten")
|
bottles_per_crate = models.PositiveSmallIntegerField()
|
||||||
bottle_size = models.FloatField("Flaschengröße (l)")
|
bottle_size = models.FloatField()
|
||||||
sale_price_per_bottle = PriceField("Verkaufspreis pro Flasche")
|
sale_price_per_bottle = PriceField()
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = (("name", "year"),)
|
|
||||||
verbose_name = "Getränk"
|
|
||||||
verbose_name_plural = "Getränke"
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} {self.year}"
|
return self.name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bottles_total(self):
|
def bottles_total(self):
|
||||||
@@ -189,71 +132,15 @@ class Drink(models.Model):
|
|||||||
def balance(self):
|
def balance(self):
|
||||||
return self.sale_price_total - self.sales_purchase_value - self.giveaway_purchase_value
|
return self.sale_price_total - self.sales_purchase_value - self.giveaway_purchase_value
|
||||||
|
|
||||||
@property
|
|
||||||
def crates_full_returned(self):
|
|
||||||
return self.crates_ordered - self.crates_purchased
|
|
||||||
|
|
||||||
@property
|
|
||||||
def crates_remaining(self):
|
|
||||||
return self.crates_purchased - self.crates_returned
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bottles_consumed(self):
|
|
||||||
return self.bottles_sold + self.bottles_given_away
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bottles_remaining(self):
|
|
||||||
return self.bottles_total - self.bottles_consumed
|
|
||||||
|
|
||||||
@property
|
|
||||||
def remaining_purchase_value(self):
|
|
||||||
return self.bottles_remaining * self.purchase_price_per_bottle
|
|
||||||
|
|
||||||
|
|
||||||
class UserPayment(models.Model):
|
|
||||||
class Method(models.TextChoices):
|
|
||||||
cash = "cash", "Bar"
|
|
||||||
paypal = "paypal", "PayPal"
|
|
||||||
bank = "bank", "Überweisung"
|
|
||||||
other = "other", "Sonstiges"
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
verbose_name="Benutzer",
|
|
||||||
to=User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="user_payments",
|
|
||||||
related_query_name="user_payment",
|
|
||||||
)
|
|
||||||
amount = PriceField("Betrag")
|
|
||||||
method = models.CharField("Methode", max_length=16, choices=Method.choices)
|
|
||||||
note = models.CharField("Notiz", max_length=64, blank=True, default="")
|
|
||||||
created_at = models.DateTimeField("Bezahlt am", auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Zahlung"
|
|
||||||
verbose_name_plural = "Zahlungen"
|
|
||||||
|
|
||||||
|
|
||||||
class Consumption(models.Model):
|
class Consumption(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
verbose_name="Benutzer",
|
to=User, on_delete=models.CASCADE, related_name="consumption_list", related_query_name="consumption"
|
||||||
to=User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="consumption_list",
|
|
||||||
related_query_name="consumption",
|
|
||||||
)
|
)
|
||||||
drink = models.ForeignKey(
|
drink = models.ForeignKey(
|
||||||
verbose_name="Getränk",
|
to=Drink, on_delete=models.CASCADE, related_name="consumption_list", related_query_name="consumption"
|
||||||
to=Drink,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="consumption_list",
|
|
||||||
related_query_name="consumption",
|
|
||||||
)
|
)
|
||||||
amount = models.PositiveSmallIntegerField("Anzahl")
|
amount = models.PositiveSmallIntegerField()
|
||||||
day = models.PositiveSmallIntegerField("Tag", choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
|
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
|
||||||
for_free = models.BooleanField("Gratis", default=False)
|
for_free = models.BooleanField(default=False)
|
||||||
created_at = models.DateTimeField("Gebucht am", auto_now_add=True, null=True)
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Konsum"
|
|
||||||
verbose_name_plural = "Konsum"
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 630 KiB |
@@ -264,24 +264,6 @@ hr {
|
|||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-poster-link {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-poster {
|
|
||||||
max-width: 90%;
|
|
||||||
max-height: 600px;
|
|
||||||
box-shadow: 0 0 24px #CC6611, 0 0 8px #EE9933;
|
|
||||||
transition: transform 150ms, box-shadow 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-poster:hover {
|
|
||||||
transform: scale(1.03);
|
|
||||||
box-shadow: 0 0 36px #EE9933, 0 0 12px #FFCC77;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* archive pages: neutral blue-grey color scheme (content only, nav/title stay amber) */
|
/* archive pages: neutral blue-grey color scheme (content only, nav/title stay amber) */
|
||||||
.archive #content h2 { color: #D8DEF0; }
|
.archive #content h2 { color: #D8DEF0; }
|
||||||
.archive #content h3 { color: #B8C0D8; }
|
.archive #content h3 { color: #B8C0D8; }
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ label {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"] {
|
||||||
input[type="number"],
|
|
||||||
select {
|
|
||||||
background-color: rgba(80, 40, 10, 0.4);
|
background-color: rgba(80, 40, 10, 0.4);
|
||||||
color: #EEEEEE;
|
color: #EEEEEE;
|
||||||
border: 2px solid #885522;
|
border: 2px solid #885522;
|
||||||
@@ -98,24 +96,9 @@ select {
|
|||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 52px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
input[type="text"]:focus {
|
||||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path fill='%23EE9933' d='M0 0l6 8 6-8z'/></svg>");
|
|
||||||
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;
|
outline: none;
|
||||||
border-color: #EE9933;
|
border-color: #EE9933;
|
||||||
}
|
}
|
||||||
@@ -140,25 +123,6 @@ button {
|
|||||||
transition: transform 80ms ease, background-color 100ms ease;
|
transition: transform 80ms ease, background-color 100ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.btn-primary {
|
|
||||||
display: block;
|
|
||||||
width: fit-content;
|
|
||||||
margin-left: auto;
|
|
||||||
background-color: #EE9933;
|
|
||||||
color: #161616;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background-color 100ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.btn-primary:hover, a.btn-primary:focus {
|
|
||||||
background-color: #FFCC77;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:active {
|
button:active {
|
||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
@@ -169,100 +133,11 @@ button:active {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #CCCCCC;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro strong {
|
|
||||||
color: #FFCC77;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-btn {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-height: 68px;
|
|
||||||
padding: 10px 8px;
|
|
||||||
border: 2px solid #885522;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #1f1f1f;
|
|
||||||
color: #FFCC77;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background-color 100ms ease, border-color 100ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-main {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-sub {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #AAAAAA;
|
|
||||||
line-height: 1.1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-btn:hover, .amount-btn:focus {
|
|
||||||
border-color: #EE9933;
|
|
||||||
background: #2a1f10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-btn-active {
|
|
||||||
border-color: #EE9933;
|
|
||||||
background: #2a1f10;
|
|
||||||
color: #EE9933;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-btn-weak {
|
|
||||||
border-color: #553311;
|
|
||||||
background: #181818;
|
|
||||||
color: #AAAAAA;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-btn-weak .amount-sub {
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-link {
|
|
||||||
color: #FFCC77;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted-left {
|
.muted-left {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #AAAAAA;
|
color: #AAAAAA;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-self {
|
|
||||||
color: #888888;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted-inline {
|
|
||||||
color: #888888;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-sub {
|
|
||||||
margin-top: 18px;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -299,76 +174,6 @@ section {
|
|||||||
text-shadow: 0 0 12px #CC6611;
|
text-shadow: 0 0 12px #CC6611;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-box-settled {
|
|
||||||
border-color: #3a7a44;
|
|
||||||
background-color: rgba(30, 80, 40, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-settled {
|
|
||||||
color: #66cc77;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: bold;
|
|
||||||
text-shadow: 0 0 12px #2a6633;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-breakdown {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px 6px;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: #889988;
|
|
||||||
}
|
|
||||||
|
|
||||||
.for-free-toggle {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 20px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
min-height: 52px;
|
|
||||||
border: 2px solid #885522;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #1f1f1f;
|
|
||||||
color: #FFCC77;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: background-color 100ms ease, border-color 100ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.for-free-toggle input {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
accent-color: #EE9933;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.for-free-toggle:has(input:checked) {
|
|
||||||
border-color: #EE9933;
|
|
||||||
background: #2a1f10;
|
|
||||||
color: #EE9933;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.drink-group-heading {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #EE9933;
|
|
||||||
margin: 14px 0 8px 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-group-heading:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-grid {
|
.drink-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@@ -381,65 +186,14 @@ section {
|
|||||||
|
|
||||||
.drink-btn {
|
.drink-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3 / 2;
|
aspect-ratio: 4 / 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
padding: 8px;
|
padding: 12px 8px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: #161616;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-btn-beer {
|
|
||||||
background: linear-gradient(180deg, #f5d088 0%, #c8831a 100%);
|
|
||||||
}
|
|
||||||
.drink-btn-beer:hover, .drink-btn-beer:focus {
|
|
||||||
background: linear-gradient(180deg, #fadfaa 0%, #dc9228 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-btn-radler {
|
|
||||||
background: linear-gradient(180deg, #faea7a 0%, #e8b038 100%);
|
|
||||||
}
|
|
||||||
.drink-btn-radler:hover, .drink-btn-radler:focus {
|
|
||||||
background: linear-gradient(180deg, #fff59a 0%, #f4c048 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-btn-sekt {
|
|
||||||
background: linear-gradient(180deg, #e890cc 0%, #e8a040 25%, #e8cc55 50%, #90e865 75%, #65b8e8 100%);
|
|
||||||
}
|
|
||||||
.drink-btn-sekt:hover, .drink-btn-sekt:focus {
|
|
||||||
background: linear-gradient(180deg, #f0a0d8 0%, #f0b055 25%, #f0dc70 50%, #a8f090 75%, #88ccf0 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-btn-alc_free_beer {
|
|
||||||
background: linear-gradient(180deg, #f5d088 0%, #7099c8 100%);
|
|
||||||
}
|
|
||||||
.drink-btn-alc_free_beer:hover, .drink-btn-alc_free_beer:focus {
|
|
||||||
background: linear-gradient(180deg, #fadfaa 0%, #88aed8 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-btn-alc_free_radler {
|
|
||||||
background: linear-gradient(180deg, #faea7a 0%, #7099c8 100%);
|
|
||||||
}
|
|
||||||
.drink-btn-alc_free_radler:hover, .drink-btn-alc_free_radler:focus {
|
|
||||||
background: linear-gradient(180deg, #fff59a 0%, #88aed8 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-btn-soft {
|
|
||||||
background: linear-gradient(180deg, #f5b070 0%, #ba6a30 100%);
|
|
||||||
}
|
|
||||||
.drink-btn-soft:hover, .drink-btn-soft:focus {
|
|
||||||
background: linear-gradient(180deg, #fac488 0%, #cc7a3a 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drink-btn-water {
|
|
||||||
background: linear-gradient(180deg, #daeef8 0%, #a8d0e6 100%);
|
|
||||||
}
|
|
||||||
.drink-btn-water:hover, .drink-btn-water:focus {
|
|
||||||
background: linear-gradient(180deg, #eaf4fc 0%, #bcdcf0 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drink-plus {
|
.drink-plus {
|
||||||
@@ -457,7 +211,7 @@ section {
|
|||||||
.drink-price {
|
.drink-price {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
opacity: 0.75;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history {
|
.history {
|
||||||
@@ -471,8 +225,7 @@ section {
|
|||||||
|
|
||||||
.history li {
|
.history li {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background-color: rgba(80, 40, 10, 0.2);
|
background-color: rgba(80, 40, 10, 0.2);
|
||||||
@@ -480,68 +233,6 @@ section {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hist-delete {
|
|
||||||
color: #885522;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hist-delete:hover, .hist-delete:focus {
|
|
||||||
color: #FFCC77;
|
|
||||||
background-color: rgba(80, 40, 10, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background-color: #cc4422;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover, .btn-danger:focus {
|
|
||||||
background-color: #ee6644;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.staff-target {
|
|
||||||
color: #66ddee;
|
|
||||||
background-color: rgba(102, 221, 238, 0.12);
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(102, 221, 238, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-username {
|
|
||||||
color: #EE9933;
|
|
||||||
background-color: rgba(238, 153, 51, 0.12);
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(238, 153, 51, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-actions > button,
|
|
||||||
.confirm-actions > a {
|
|
||||||
flex: 1;
|
|
||||||
width: auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 14px 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
min-height: 52px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history li:nth-child(odd) {
|
.history li:nth-child(odd) {
|
||||||
background-color: rgba(80, 40, 10, 0.35);
|
background-color: rgba(80, 40, 10, 0.35);
|
||||||
}
|
}
|
||||||
@@ -567,42 +258,10 @@ section {
|
|||||||
color: #FFCC77;
|
color: #FFCC77;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-actions {
|
.logout-form {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-actions form {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 14px 20px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 2px solid #885522;
|
|
||||||
color: #885522;
|
|
||||||
text-decoration: none;
|
|
||||||
text-align: center;
|
|
||||||
min-height: 52px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary-link:hover, .btn-secondary-link:focus {
|
|
||||||
background-color: rgba(80, 40, 10, 0.4);
|
|
||||||
color: #FFCC77;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
background-color: rgba(238, 153, 51, 0.15);
|
background-color: rgba(238, 153, 51, 0.15);
|
||||||
border: 2px solid #EE9933;
|
border: 2px solid #EE9933;
|
||||||
@@ -718,53 +377,3 @@ section {
|
|||||||
background-color: rgba(80, 40, 10, 0.4);
|
background-color: rgba(80, 40, 10, 0.4);
|
||||||
color: #FFCC77;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history li.fin-row {
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-btn-paypal {
|
|
||||||
background-color: #003087;
|
|
||||||
color: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-btn-paypal:hover, .link-btn-paypal:focus {
|
|
||||||
background-color: #0070BA;
|
|
||||||
color: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|||||||
+41
-812
@@ -1,14 +1,11 @@
|
|||||||
import math
|
from datetime import datetime
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from decimal import Decimal
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from django.conf import settings
|
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 import authenticate, login, logout
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.models import Case, CharField, Count, F, IntegerField, Sum, Value, When
|
from django.db.models import F, Sum
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -16,24 +13,16 @@ from django.utils import timezone
|
|||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
|
from gaehsnitz.models import Consumption, Drink
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
ANONYMOUS_USERNAME = "anonym"
|
|
||||||
|
|
||||||
BERLIN = ZoneInfo("Europe/Berlin")
|
BERLIN = ZoneInfo("Europe/Berlin")
|
||||||
# Festival window: 2026-05-30 10:00 – 2026-06-14 22:00 Berlin time.
|
BOOKING_START = datetime(2026, 6, 11, 0, 0, 0, tzinfo=BERLIN)
|
||||||
BOOKING_START = datetime(2026, 5, 30, 10, 0, 0, tzinfo=BERLIN)
|
BOOKING_END = datetime(2026, 6, 14, 23, 59, 59, tzinfo=BERLIN)
|
||||||
BOOKING_END = datetime(2026, 6, 17, 23, 59, 0, tzinfo=BERLIN)
|
READONLY_END = datetime(2026, 6, 21, 23, 59, 59, tzinfo=BERLIN)
|
||||||
|
|
||||||
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
|
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():
|
def _now():
|
||||||
@@ -48,120 +37,46 @@ def _phase():
|
|||||||
return "before"
|
return "before"
|
||||||
if now <= BOOKING_END:
|
if now <= BOOKING_END:
|
||||||
return "booking"
|
return "booking"
|
||||||
|
if now <= READONLY_END:
|
||||||
|
return "readonly"
|
||||||
return "closed"
|
return "closed"
|
||||||
|
|
||||||
|
|
||||||
def _require_open(request):
|
def _require_open(request):
|
||||||
"""Redirect to closed page when tool is not in booking phase."""
|
"""Raise 404 once tool is fully closed. Return current phase otherwise."""
|
||||||
phase = _phase()
|
phase = _phase()
|
||||||
if phase in ("before", "closed"):
|
if phase == "closed":
|
||||||
return HttpResponseRedirect(reverse("suff:closed")), None
|
raise Http404
|
||||||
return None, phase
|
return phase
|
||||||
|
|
||||||
|
|
||||||
def _current_festival_day():
|
def _current_festival_day():
|
||||||
# Day rolls at 06:00 Berlin: bookings before 06:00 count as previous day.
|
weekday = _now().weekday()
|
||||||
weekday = _shifted_local(timezone.now()).weekday()
|
|
||||||
return DAY_BY_WEEKDAY.get(weekday, 4)
|
return DAY_BY_WEEKDAY.get(weekday, 4)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_name(raw):
|
def _normalize_name(raw):
|
||||||
return slugify(raw or "")[:20]
|
return slugify(raw or "")[:150]
|
||||||
|
|
||||||
|
|
||||||
def _username_error(username):
|
|
||||||
if len(username) < 2:
|
|
||||||
return "Name muss mindestens 2 Zeichen lang sein."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _user_has_activity(user):
|
|
||||||
return Consumption.objects.filter(user=user).exists() or UserPayment.objects.filter(user=user).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_anonymous(user):
|
|
||||||
return user.username == ANONYMOUS_USERNAME
|
|
||||||
|
|
||||||
|
|
||||||
_WEAK_PINS = {"123", "234", "345", "456", "567", "678", "789", "987", "876", "765", "654", "543", "432", "321"} | {d * 3 for d in "0123456789"}
|
|
||||||
|
|
||||||
|
|
||||||
def _pin_error(pin):
|
|
||||||
if not (pin.isdigit() and len(pin) == 3):
|
|
||||||
return "PIN muss aus genau 3 Ziffern bestehen."
|
|
||||||
if pin in _WEAK_PINS:
|
|
||||||
return "Diese PIN ist zu einfach. Bitte eine andere wählen."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _staff_required(view):
|
|
||||||
def wrapped(request, *args, **kwargs):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return HttpResponseRedirect(reverse("suff:name"))
|
|
||||||
if not request.user.is_staff:
|
|
||||||
raise Http404
|
|
||||||
return view(request, *args, **kwargs)
|
|
||||||
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
|
|
||||||
def _drink_grid_qs():
|
|
||||||
return (
|
|
||||||
Drink.objects.filter(year=current_year())
|
|
||||||
.annotate(
|
|
||||||
alcohol_order=Case(
|
|
||||||
When(category__in=["beer", "radler", "sekt"], then=Value(0)),
|
|
||||||
default=Value(1),
|
|
||||||
output_field=IntegerField(),
|
|
||||||
),
|
|
||||||
alcohol_label=Case(
|
|
||||||
When(category__in=["beer", "radler", "sekt"], then=Value("mit Alkohol")),
|
|
||||||
default=Value("ohne Alkohol"),
|
|
||||||
output_field=CharField(),
|
|
||||||
),
|
|
||||||
category_order=Case(
|
|
||||||
When(category="beer", then=Value(0)),
|
|
||||||
When(category="radler", then=Value(1)),
|
|
||||||
When(category="sekt", then=Value(2)),
|
|
||||||
When(category="alc_free_beer", then=Value(3)),
|
|
||||||
When(category="alc_free_radler", then=Value(4)),
|
|
||||||
When(category="soft", then=Value(5)),
|
|
||||||
When(category="water", then=Value(6)),
|
|
||||||
default=Value(99),
|
|
||||||
output_field=IntegerField(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.order_by("alcohol_order", "category_order", "name")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tab_context(user):
|
def _tab_context(user):
|
||||||
year = current_year()
|
consumption = user.consumption_list.select_related("drink").order_by("-created_at", "-id")
|
||||||
consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id")
|
paid = (
|
||||||
total = (
|
user.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(total=Sum("cost"))[
|
||||||
user.paid_drinks.filter(drink__year=year)
|
"total"
|
||||||
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
|
]
|
||||||
.aggregate(total=Sum("cost"))["total"]
|
|
||||||
or 0
|
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 {
|
return {
|
||||||
"tab_user": user,
|
"tab_user": user,
|
||||||
"consumption_list": consumption,
|
"consumption_list": consumption,
|
||||||
"total": total,
|
"total": paid,
|
||||||
"paid": paid,
|
|
||||||
"open_balance": total - paid,
|
|
||||||
"user_payments": payments,
|
|
||||||
"payment_methods": [(m, l) for m, l in UserPayment.Method.choices if m != "other"],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def name_view(request):
|
def name_view(request):
|
||||||
redirect, phase = _require_open(request)
|
phase = _require_open(request)
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return HttpResponseRedirect(reverse("suff:me"))
|
return HttpResponseRedirect(reverse("suff:me"))
|
||||||
|
|
||||||
@@ -169,8 +84,8 @@ def name_view(request):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
raw_name = request.POST.get("name", "")
|
raw_name = request.POST.get("name", "")
|
||||||
username = _normalize_name(raw_name)
|
username = _normalize_name(raw_name)
|
||||||
if not username or (error := _username_error(username)):
|
if not username:
|
||||||
error = error or "Bitte einen Namen eingeben."
|
error = "Bitte einen Namen eingeben."
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
existing = User.objects.get(username=username)
|
existing = User.objects.get(username=username)
|
||||||
@@ -178,22 +93,16 @@ def name_view(request):
|
|||||||
existing = None
|
existing = None
|
||||||
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return render(
|
request.session["pending_username"] = username
|
||||||
request,
|
request.session["pending_mode"] = "create"
|
||||||
"suff/party_over.html",
|
return HttpResponseRedirect(reverse("suff:pin"))
|
||||||
{"phase": phase, "username": username},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not existing.pin:
|
if not existing.pin:
|
||||||
if _user_has_activity(existing):
|
return render(
|
||||||
return render(
|
request,
|
||||||
request,
|
"suff/no_pin.html",
|
||||||
"suff/no_pin.html",
|
{"phase": phase, "username": username},
|
||||||
{"phase": phase, "username": username},
|
)
|
||||||
)
|
|
||||||
request.session["pending_username"] = username
|
|
||||||
request.session["pending_mode"] = "claim"
|
|
||||||
return HttpResponseRedirect(reverse("suff:pin"))
|
|
||||||
|
|
||||||
request.session["pending_username"] = username
|
request.session["pending_username"] = username
|
||||||
request.session["pending_mode"] = "login"
|
request.session["pending_mode"] = "login"
|
||||||
@@ -204,19 +113,17 @@ def name_view(request):
|
|||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def pin_view(request):
|
def pin_view(request):
|
||||||
redirect, phase = _require_open(request)
|
phase = _require_open(request)
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
username = request.session.get("pending_username")
|
username = request.session.get("pending_username")
|
||||||
mode = request.session.get("pending_mode")
|
mode = request.session.get("pending_mode")
|
||||||
if not username or mode not in ("create", "login", "claim"):
|
if not username or mode not in ("create", "login"):
|
||||||
return HttpResponseRedirect(reverse("suff:name"))
|
return HttpResponseRedirect(reverse("suff:name"))
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
pin = request.POST.get("pin", "")
|
pin = request.POST.get("pin", "")
|
||||||
if error := _pin_error(pin):
|
if not (pin.isdigit() and len(pin) == 3):
|
||||||
pass
|
error = "PIN muss aus genau 3 Ziffern bestehen."
|
||||||
elif mode == "create":
|
elif mode == "create":
|
||||||
if User.objects.filter(username=username).exists():
|
if User.objects.filter(username=username).exists():
|
||||||
error = "Name bereits vergeben."
|
error = "Name bereits vergeben."
|
||||||
@@ -232,23 +139,6 @@ def pin_view(request):
|
|||||||
request.session.pop("pending_username", None)
|
request.session.pop("pending_username", None)
|
||||||
request.session.pop("pending_mode", None)
|
request.session.pop("pending_mode", None)
|
||||||
return HttpResponseRedirect(reverse("suff:me"))
|
return HttpResponseRedirect(reverse("suff:me"))
|
||||||
elif mode == "claim":
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return HttpResponseRedirect(reverse("suff:name"))
|
|
||||||
if user.pin or _user_has_activity(user):
|
|
||||||
return HttpResponseRedirect(reverse("suff:name"))
|
|
||||||
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:
|
else:
|
||||||
authed = authenticate(request, username=username, pin=pin)
|
authed = authenticate(request, username=username, pin=pin)
|
||||||
if authed is None:
|
if authed is None:
|
||||||
@@ -271,33 +161,11 @@ def pin_view(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def change_pin_view(request):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
|
|
||||||
error = None
|
|
||||||
if request.method == "POST":
|
|
||||||
pin = request.POST.get("pin", "")
|
|
||||||
if error := _pin_error(pin):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
request.user.set_pin(pin)
|
|
||||||
request.user.save()
|
|
||||||
return HttpResponseRedirect(reverse("suff:me"))
|
|
||||||
|
|
||||||
return render(request, "suff/change_pin.html", {"phase": phase, "error": error})
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def me_view(request):
|
def me_view(request):
|
||||||
redirect, phase = _require_open(request)
|
phase = _require_open(request)
|
||||||
if redirect:
|
drinks = Drink.objects.order_by("name") if phase == "booking" else Drink.objects.none()
|
||||||
return redirect
|
|
||||||
drinks = _drink_grid_qs() if phase == "booking" else Drink.objects.none()
|
|
||||||
booked_drink = None
|
booked_drink = None
|
||||||
booked_id = request.GET.get("booked")
|
booked_id = request.GET.get("booked")
|
||||||
if booked_id:
|
if booked_id:
|
||||||
@@ -312,7 +180,6 @@ def me_view(request):
|
|||||||
"drinks": drinks,
|
"drinks": drinks,
|
||||||
"current_day": _current_festival_day(),
|
"current_day": _current_festival_day(),
|
||||||
"booked_drink": booked_drink,
|
"booked_drink": booked_drink,
|
||||||
"paid_toast": request.GET.get("paid") == "1",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return render(request, "suff/me.html", context)
|
return render(request, "suff/me.html", context)
|
||||||
@@ -321,19 +188,11 @@ def me_view(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def book_view(request):
|
def book_view(request):
|
||||||
redirect, phase = _require_open(request)
|
phase = _require_open(request)
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
if phase != "booking":
|
if phase != "booking":
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
if _is_anonymous(request.user):
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
drink_id = request.POST.get("drink_id")
|
drink_id = request.POST.get("drink_id")
|
||||||
booking_mode = request.POST.get("booking_mode", "normal")
|
|
||||||
for_free = booking_mode == "for_free"
|
|
||||||
cash_paid = booking_mode == "cash_paid"
|
|
||||||
try:
|
try:
|
||||||
drink = Drink.objects.get(pk=int(drink_id))
|
drink = Drink.objects.get(pk=int(drink_id))
|
||||||
except (Drink.DoesNotExist, TypeError, ValueError):
|
except (Drink.DoesNotExist, TypeError, ValueError):
|
||||||
@@ -344,643 +203,13 @@ def book_view(request):
|
|||||||
drink=drink,
|
drink=drink,
|
||||||
amount=1,
|
amount=1,
|
||||||
day=_current_festival_day(),
|
day=_current_festival_day(),
|
||||||
for_free=for_free,
|
for_free=False,
|
||||||
)
|
)
|
||||||
if cash_paid:
|
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
|
||||||
UserPayment.objects.create(
|
|
||||||
user=request.user,
|
|
||||||
amount=drink.sale_price_per_bottle,
|
|
||||||
method=UserPayment.Method.cash,
|
|
||||||
note=f"Auto: {drink.name}",
|
|
||||||
)
|
|
||||||
free_suffix = "&free=1" if for_free else ""
|
|
||||||
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}{free_suffix}")
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def delete_consumption_view(request, consumption_id):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
if phase != "booking":
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
try:
|
|
||||||
consumption = Consumption.objects.select_related("drink").get(pk=consumption_id, user=request.user)
|
|
||||||
except Consumption.DoesNotExist:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
consumption.delete()
|
|
||||||
return HttpResponseRedirect(reverse("suff:me"))
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"suff/confirm_delete.html",
|
|
||||||
{"phase": phase, "consumption": consumption},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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):
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_staff_target(username):
|
|
||||||
try:
|
|
||||||
return User.objects.get(username=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
|
|
||||||
@_staff_required
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def staff_register_view(request):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
|
|
||||||
error = None
|
|
||||||
prefill_name = ""
|
|
||||||
prefill_pin = ""
|
|
||||||
if request.method == "POST":
|
|
||||||
raw_name = request.POST.get("name", "")
|
|
||||||
username = _normalize_name(raw_name)
|
|
||||||
pin = (request.POST.get("pin") or "").strip()
|
|
||||||
prefill_name = raw_name
|
|
||||||
prefill_pin = pin
|
|
||||||
|
|
||||||
if not username:
|
|
||||||
error = "Bitte einen Namen eingeben."
|
|
||||||
elif error := _username_error(username):
|
|
||||||
pass
|
|
||||||
elif User.objects.filter(username=username).exists():
|
|
||||||
error = "Name bereits vergeben."
|
|
||||||
elif pin and (error := _pin_error(pin)):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
user = User(username=username)
|
|
||||||
if pin:
|
|
||||||
user.set_pin(pin)
|
|
||||||
user.save()
|
|
||||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"suff/staff_register.html",
|
|
||||||
{
|
|
||||||
"phase": phase,
|
|
||||||
"error": error,
|
|
||||||
"prefill_name": prefill_name,
|
|
||||||
"prefill_pin": prefill_pin,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@_staff_required
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def staff_pin_reset_view(request, username):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
|
|
||||||
target = _get_staff_target(username)
|
|
||||||
if _is_anonymous(target):
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
error = None
|
|
||||||
if request.method == "POST":
|
|
||||||
pin = (request.POST.get("pin") or "").strip()
|
|
||||||
if error := _pin_error(pin):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
target.set_pin(pin)
|
|
||||||
target.save()
|
|
||||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"suff/staff_pin_reset.html",
|
|
||||||
{"phase": phase, "tab_user": target, "error": error},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@_staff_required
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def staff_index_view(request):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
|
|
||||||
anon = User.objects.filter(username=ANONYMOUS_USERNAME).first()
|
|
||||||
others = User.objects.exclude(username=ANONYMOUS_USERNAME).order_by("username")
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"suff/staff_index.html",
|
|
||||||
{"phase": phase, "anonymous": anon, "users": others},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@_staff_required
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def staff_user_view(request, username):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
|
|
||||||
target = _get_staff_target(username)
|
|
||||||
drinks = _drink_grid_qs() 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(target)
|
|
||||||
context.update(
|
|
||||||
{
|
|
||||||
"phase": phase,
|
|
||||||
"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),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return render(request, "suff/staff_user.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@_staff_required
|
|
||||||
@require_http_methods(["POST"])
|
|
||||||
def staff_book_view(request, username):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
if phase != "booking":
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
target = _get_staff_target(username)
|
|
||||||
drink_id = request.POST.get("drink_id")
|
|
||||||
booking_mode = request.POST.get("booking_mode", "normal")
|
|
||||||
for_free = booking_mode == "for_free"
|
|
||||||
cash_paid = booking_mode == "cash_paid"
|
|
||||||
try:
|
|
||||||
drink = Drink.objects.get(pk=int(drink_id))
|
|
||||||
except (Drink.DoesNotExist, TypeError, ValueError):
|
|
||||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
|
||||||
|
|
||||||
Consumption.objects.create(
|
|
||||||
user=target,
|
|
||||||
drink=drink,
|
|
||||||
amount=1,
|
|
||||||
day=_current_festival_day(),
|
|
||||||
for_free=for_free,
|
|
||||||
)
|
|
||||||
if cash_paid or (_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}",
|
|
||||||
)
|
|
||||||
suffix = "&free=1" if for_free else ""
|
|
||||||
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}{suffix}")
|
|
||||||
|
|
||||||
|
|
||||||
@_staff_required
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def staff_pay_view(request, username):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
|
|
||||||
target = _get_staff_target(username)
|
|
||||||
if _is_anonymous(target):
|
|
||||||
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=target,
|
|
||||||
amount=amount,
|
|
||||||
method=method,
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?paid=1")
|
|
||||||
|
|
||||||
context = _tab_context(target)
|
|
||||||
context.update({"phase": phase, "error": error, "is_anonymous_target": False})
|
|
||||||
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):
|
|
||||||
redirect, phase = _require_open(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
if phase != "booking":
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
target = _get_staff_target(username)
|
|
||||||
try:
|
|
||||||
consumption = Consumption.objects.select_related("drink").get(pk=consumption_id, user=target)
|
|
||||||
except Consumption.DoesNotExist:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
if _is_anonymous(target):
|
|
||||||
matching = (
|
|
||||||
UserPayment.objects.filter(
|
|
||||||
user=target,
|
|
||||||
method=UserPayment.Method.cash,
|
|
||||||
amount=consumption.drink.sale_price_per_bottle,
|
|
||||||
note=f"Auto: {consumption.drink.name}",
|
|
||||||
)
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if matching:
|
|
||||||
matching.delete()
|
|
||||||
consumption.delete()
|
|
||||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"suff/staff_confirm_delete.html",
|
|
||||||
{"phase": phase, "consumption": consumption, "tab_user": target},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def dashboard_view(request):
|
|
||||||
year = current_year()
|
|
||||||
|
|
||||||
CASH_PREFILL = Decimal("500.00")
|
|
||||||
|
|
||||||
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
|
||||||
total_costs = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
|
||||||
cash_raw = UserPayment.objects.filter(created_at__year=year, method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
|
||||||
cash_net = cash_raw - CASH_PREFILL
|
|
||||||
non_cash = UserPayment.objects.filter(created_at__year=year).exclude(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
|
||||||
user_payments_total = cash_net + non_cash # real income, prefill excluded
|
|
||||||
|
|
||||||
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)
|
|
||||||
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():
|
|
||||||
consumed = user.consumed_drinks_price
|
|
||||||
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": open_amount,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
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"]})
|
|
||||||
|
|
||||||
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}
|
|
||||||
cash_total = method_split.get(UserPayment.Method.cash, 0)
|
|
||||||
|
|
||||||
# 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_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,
|
|
||||||
"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,
|
|
||||||
"cash_total": cash_total,
|
|
||||||
"cash_prefill": CASH_PREFILL,
|
|
||||||
"cash_net": cash_net,
|
|
||||||
"top_free_recipient": top_free_recipient,
|
|
||||||
"free_total_count": free_total_count,
|
|
||||||
}
|
|
||||||
return render(request, "suff/dashboard.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def logout_view(request):
|
def logout_view(request):
|
||||||
|
_require_open(request)
|
||||||
logout(request)
|
logout(request)
|
||||||
return HttpResponseRedirect(reverse("suff:name"))
|
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})
|
|
||||||
|
|||||||
@@ -2,54 +2,18 @@ from django.urls import path
|
|||||||
|
|
||||||
from gaehsnitz.suff import (
|
from gaehsnitz.suff import (
|
||||||
book_view,
|
book_view,
|
||||||
change_pin_view,
|
|
||||||
closed_view,
|
|
||||||
dashboard_view,
|
|
||||||
delete_consumption_view,
|
|
||||||
delete_payment_view,
|
|
||||||
logout_view,
|
logout_view,
|
||||||
me_view,
|
me_view,
|
||||||
name_view,
|
name_view,
|
||||||
pay_view,
|
|
||||||
pin_view,
|
pin_view,
|
||||||
staff_book_view,
|
|
||||||
staff_delete_consumption_view,
|
|
||||||
staff_delete_payment_view,
|
|
||||||
staff_index_view,
|
|
||||||
staff_pay_view,
|
|
||||||
staff_pin_reset_view,
|
|
||||||
staff_register_view,
|
|
||||||
staff_user_view,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "suff"
|
app_name = "suff"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", name_view, name="name"),
|
path("", name_view, name="name"),
|
||||||
path("closed/", closed_view, name="closed"),
|
|
||||||
path("pin/", pin_view, name="pin"),
|
path("pin/", pin_view, name="pin"),
|
||||||
path("me/", me_view, name="me"),
|
path("me/", me_view, name="me"),
|
||||||
path("me/change-pin/", change_pin_view, name="change_pin"),
|
|
||||||
path("book/", book_view, name="book"),
|
path("book/", book_view, name="book"),
|
||||||
path("book/<int:consumption_id>/delete/", delete_consumption_view, name="delete_consumption"),
|
|
||||||
path("pay/", pay_view, name="pay"),
|
|
||||||
path("pay/<int:payment_id>/delete/", delete_payment_view, name="delete_payment"),
|
|
||||||
path("dashboard/", dashboard_view, name="dashboard"),
|
|
||||||
path("staff/", staff_index_view, name="staff_index"),
|
|
||||||
path("staff/new/", staff_register_view, name="staff_register"),
|
|
||||||
path("staff/u/<str:username>/", staff_user_view, name="staff_user"),
|
|
||||||
path("staff/u/<str:username>/pin/", staff_pin_reset_view, name="staff_pin_reset"),
|
|
||||||
path("staff/u/<str:username>/book/", staff_book_view, name="staff_book"),
|
|
||||||
path("staff/u/<str:username>/pay/", staff_pay_view, name="staff_pay"),
|
|
||||||
path(
|
|
||||||
"staff/u/<str:username>/pay/<int:payment_id>/delete/",
|
|
||||||
staff_delete_payment_view,
|
|
||||||
name="staff_delete_payment",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"staff/u/<str:username>/book/<int:consumption_id>/delete/",
|
|
||||||
staff_delete_consumption_view,
|
|
||||||
name="staff_delete_consumption",
|
|
||||||
),
|
|
||||||
path("logout/", logout_view, name="logout"),
|
path("logout/", logout_view, name="logout"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{% extends "gaehsnitz/base.html" %}
|
{% extends "gaehsnitz/base.html" %} {% load money %} {% block content %}
|
||||||
{% load static %} {% load money %}
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<a href="{% static 'gaehsnitz/poster-2026.jpg' %}" target="_blank" class="main-poster-link">
|
<h2>Gähsnitz Open Air</h2>
|
||||||
<img src="{% static 'gaehsnitz/poster-2026.jpg' %}" alt="Plakat 2026" class="main-poster">
|
<p>Unser eigenes kleines Festival ... oder auch nur eine große Gartenparty.</p>
|
||||||
</a>
|
<p>--- Plakat folgt ---</p>
|
||||||
|
<p>Do 11. bis So 14. Juni 2026</p>
|
||||||
|
<p>Gähsnitzer Ring 9, 04603 Nobitz</p>
|
||||||
|
<p>Live-Bands: Knast, Kotpiloten, Melo-Komplott, Mörtel, Quast</p>
|
||||||
|
|
||||||
<h2>Allgemeine Infos</h2>
|
<h2>Allgemeine Infos</h2>
|
||||||
|
|
||||||
<p>🗺️ Adresse: Gähsnitzer Ring 9, 04603 Nobitz</p>
|
|
||||||
<p>💰 Eintritt gegen Spende. Getränke für'n schmalen Taler. Beides hilft uns sehr bei der Finanzierung. :)</p>
|
<p>💰 Eintritt gegen Spende. Getränke für'n schmalen Taler. Beides hilft uns sehr bei der Finanzierung. :)</p>
|
||||||
<p>🤫 Es ist eine Privatparty - bitte keine öffentliche Werbung machen! Bringt aber gern Freund*innen mit.</p>
|
<p>🤫 Es ist eine Privatparty - bitte keine öffentliche Werbung machen! Bringt aber gern Freund*innen mit.</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -44,10 +44,7 @@
|
|||||||
<p>Donnerstag: Aufbau, Grundeinstellung, Soundcheck, evtl. Jam-Session 😎</p>
|
<p>Donnerstag: Aufbau, Grundeinstellung, Soundcheck, evtl. Jam-Session 😎</p>
|
||||||
<p>Freitag:</p>
|
<p>Freitag:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>16:30 Umbau + Line-Check Six Good Years</li>
|
<li>18:45 Umbau + Line-Check Melo-Komplott</li>
|
||||||
<li>17:00 Six Good Years</li>
|
|
||||||
<li>18:00 Essenspause</li>
|
|
||||||
<li>19:00 Umbau + Line-Check Melo-Komplott</li>
|
|
||||||
<li>19:30 Melo-Komplott</li>
|
<li>19:30 Melo-Komplott</li>
|
||||||
<li>20:30 Umbau + Line-Check Mörtel</li>
|
<li>20:30 Umbau + Line-Check Mörtel</li>
|
||||||
<li>21:00 Mörtel</li>
|
<li>21:00 Mörtel</li>
|
||||||
@@ -64,14 +61,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Backline für alle</h3>
|
<h3>Backline für alle</h3>
|
||||||
<p>Drums von <span class="marked">Josi</span> – bitte mit ❤ behandeln</p>
|
<p>Drums von Josi – bitte mit ❤ behandeln</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Bassdrum</li>
|
<li>Bassdrum</li>
|
||||||
<li>1 Hänge- + 2 Standtoms</li>
|
<li>1 Hänge- + 2 Standtoms</li>
|
||||||
<li>Stative für Snare, Hi-Hat, 1x Crash und 1x Ride</li>
|
<li>Stative für Snare, Hi-Hat, 1x Crash und 1x Ride</li>
|
||||||
<li>Drum-Hocker</li>
|
<li>Drum-Hocker</li>
|
||||||
<li>Bassbox: Markbass 4x10", 4 Ohm, Speakon</li>
|
<li>Bassbox: Markbass 4x10", 4 Ohm, Speakon</li>
|
||||||
<li>Gitarrenbox: 2x12", 8 Ohm, Klinke <span class="marked">(Steve)</span></li>
|
<li>Gitarrenbox: <span class="marked">Ohli/Steve fragen / reicht eine?</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Was mitgenutzt werden kann ...</h3>
|
<h3>Was mitgenutzt werden kann ...</h3>
|
||||||
@@ -96,8 +93,8 @@
|
|||||||
|
|
||||||
<h3>Nur zum Jammen / was wir ungern verleihen ...</h3>
|
<h3>Nur zum Jammen / was wir ungern verleihen ...</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Snare <span class="marked">(Tobi)</span></li>
|
<li>Snare (Josi)</li>
|
||||||
<li>Becken: Crash, Ride, HiHat <span class="marked">(Tobi)</span></li>
|
<li>Becken: Crash, Ride, HiHat (Tobi)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Mikrofonierung</h3>
|
<h3>Mikrofonierung</h3>
|
||||||
@@ -107,14 +104,14 @@
|
|||||||
<li>Toms: 3x Audix f2</li>
|
<li>Toms: 3x Audix f2</li>
|
||||||
<li>Overheads: 2x Rode M5</li>
|
<li>Overheads: 2x Rode M5</li>
|
||||||
<li>Amps: 1x Sennheiser E609 | 2x Superlux PRA 628</li>
|
<li>Amps: 1x Sennheiser E609 | 2x Superlux PRA 628</li>
|
||||||
<li>Gesang: 1x Shure Beta 58 | 2x Shure SM58 <span class="marked">(geplant)</span></li>
|
<li>Gesang: 1x Shure Beta 58 | <span class="marked">noch ein paar auftreiben</span></li>
|
||||||
<li>DI: 1x Mono (Pedalboard) | 1x Mono <span class="marked">(PR)</span> | 2x Mono Palmer <span class="marked">(geplant)</span></li>
|
<li>DI: <span class="marked">im Proberaum schauen / ausleihen</span></li>
|
||||||
<li>Stative: <span class="marked">mal Inventur machen</span></li>
|
<li>Stative: <span class="marked">mal Inventur machen</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Monitoring</h3>
|
<h3>Monitoring</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>2x Alto PA <span class="marked">(PR)</span></li>
|
<li class="marked">2x Alto Proberaum-PA reservieren</li>
|
||||||
<li class="marked">1x JBL alte Proberaum-PA ausprobieren</li>
|
<li class="marked">1x JBL alte Proberaum-PA ausprobieren</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -127,7 +124,7 @@
|
|||||||
|
|
||||||
<h3>Beleuchtung</h3>
|
<h3>Beleuchtung</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li class="marked">alles von Franz</li>
|
<li class="marked">Carsten / Jakob fragen?</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Kabel</h3>
|
<h3>Kabel</h3>
|
||||||
|
|||||||
@@ -14,30 +14,5 @@
|
|||||||
<h1>Suff</h1>
|
<h1>Suff</h1>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<script>
|
|
||||||
// Track last clicked submit button so its value survives being disabled on submit.
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
var btn = e.target.closest('button[type="submit"], button:not([type])');
|
|
||||||
if (!btn || !btn.form) return;
|
|
||||||
var form = btn.form;
|
|
||||||
var existing = form.querySelector('input[data-submitter]');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
if (btn.name) {
|
|
||||||
var hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = btn.name;
|
|
||||||
hidden.value = btn.value;
|
|
||||||
hidden.setAttribute('data-submitter', '1');
|
|
||||||
form.appendChild(hidden);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('submit', function(e) {
|
|
||||||
var form = e.target;
|
|
||||||
form.querySelectorAll('button[type="submit"], button:not([type])').forEach(function(btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.style.opacity = '0.5';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h2>PIN ändern</h2>
|
|
||||||
|
|
||||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:change_pin' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label>
|
|
||||||
Neue PIN:
|
|
||||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}" maxlength="3"
|
|
||||||
minlength="3" required autofocus autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<button type="submit">PIN speichern</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p><a href="{% url 'suff:me' %}">Abbrechen</a></p>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% if phase == "before" %}
|
|
||||||
<p>Hier könnt ihr während des Festivals eure Getränke selbst buchen und den Überblick über eure Rechnung behalten — die digitale Strichliste.</p>
|
|
||||||
<p>Das Tool startet am <strong>{{ booking_start|date:"d.m.Y" }}</strong> — schaut dann nochmal rein!</p>
|
|
||||||
{% else %}
|
|
||||||
<p>Das Festival ist vorbei. Das Tool ist jetzt deaktiviert.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h2>Buchung löschen?</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Willst du wirklich
|
|
||||||
<strong>{{ consumption.drink.name }}</strong>
|
|
||||||
({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %})
|
|
||||||
von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %}
|
|
||||||
löschen?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:delete_consumption' consumption.id %}" class="confirm-actions">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
|
||||||
<a href="{% url 'suff:me' %}" class="btn-secondary">Nein, zurück</a>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h2>Zahlung löschen?</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Willst du wirklich diese Zahlung löschen?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section class="total-box">
|
|
||||||
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} – {{ payment.note }}{% endif %}</span>
|
|
||||||
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
|
|
||||||
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:delete_payment' payment.id %}" class="confirm-actions">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
|
||||||
<a href="{% url 'suff:pay' %}" class="btn-secondary">Nein, zurück</a>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
{% load money %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h2>Dashboard {{ year }}</h2>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Finanzen</h3>
|
|
||||||
<div class="progress-wrap">
|
|
||||||
<div class="progress-bar" style="width: {{ finance_pct_capped }}%;"></div>
|
|
||||||
<span class="progress-label">{{ finance_pct }}%</span>
|
|
||||||
</div>
|
|
||||||
<ul class="history">
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what"><b>Ausgaben gesamt</b></span>
|
|
||||||
<span class="hist-price"><b>{{ total_costs|euro }}</b></span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what"><b>Einnahmen gesamt</b></span>
|
|
||||||
<span class="hist-price"><b>{{ income_total|euro }}</b></span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what"><b>Bilanz</b></span>
|
|
||||||
<span class="hist-price"><b>{{ finance_balance|euro }}</b></span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class="history history-sub">
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Spenden (Donations)</span>
|
|
||||||
<span class="hist-price">{{ total_donations|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Cashless (User-Tab)</span>
|
|
||||||
<span class="hist-price">{{ user_payments_total|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Kasse (bar, nach Vorschuss)</span>
|
|
||||||
<span class="hist-price">{{ cash_net|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Kassen-Vorschuss</span>
|
|
||||||
<span class="hist-price">{{ cash_prefill|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Einkaufspreis Getränke</span>
|
|
||||||
<span class="hist-price">{{ purchase_cost|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Verkaufserlös Getränke</span>
|
|
||||||
<span class="hist-price">{{ sales_revenue|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Gratis-Getränke (EK-Wert)</span>
|
|
||||||
<span class="hist-price">{{ free_drinks_value|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Unverkauft (EK-Wert)</span>
|
|
||||||
<span class="hist-price">{{ unsold_purchase_value|euro }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="fin-row">
|
|
||||||
<span class="hist-what">Unverkauft (potenzieller VK)</span>
|
|
||||||
<span class="hist-price">{{ unsold_sale_value|euro }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Getränke</h3>
|
|
||||||
<ul class="history">
|
|
||||||
{% for d in drink_rows %}
|
|
||||||
<li class="dash-row">
|
|
||||||
<span class="hist-what"><b>{{ d.name }}</b></span>
|
|
||||||
<span class="hist-when">{{ d.sold }}/{{ d.total }} verkauft</span>
|
|
||||||
<span class="hist-price">{{ d.balance|euro }}</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Offene Beträge</h3>
|
|
||||||
{% if user_rows %}
|
|
||||||
<ul class="history">
|
|
||||||
{% for u in user_rows %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">{{ u.username }}</span>
|
|
||||||
<span class="hist-what">{{ u.consumed|euro }} − {{ u.paid|euro }}</span>
|
|
||||||
<span class="hist-price">{{ u.open|euro }}</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted-left">Niemand hat etwas konsumiert.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Fun Facts: Trinker</h3>
|
|
||||||
<ul class="history">
|
|
||||||
{% if top_spender %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Top-Zecher</span>
|
|
||||||
<span class="hist-what">{{ top_spender.username }}</span>
|
|
||||||
<span class="hist-price">{{ top_spender.total|euro }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if variety_champ %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Vielfalt-Champion</span>
|
|
||||||
<span class="hist-what">{{ variety_champ.user__username }}</span>
|
|
||||||
<span class="hist-price">{{ variety_champ.distinct }} Sorten</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% for f in top_per_day %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Top {{ f.label }}</span>
|
|
||||||
<span class="hist-what">{{ f.username }}</span>
|
|
||||||
<span class="hist-price">{{ f.amount }} Flaschen</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Fun Facts: Getränke</h3>
|
|
||||||
<ul class="history">
|
|
||||||
{% if top_drink %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Top-Getränk</span>
|
|
||||||
<span class="hist-what">{{ top_drink.name }}</span>
|
|
||||||
<span class="hist-price">{{ top_drink.amount }}x</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Bier-Anteil</span>
|
|
||||||
<span class="hist-what">am Konsum</span>
|
|
||||||
<span class="hist-price">{{ beer_share }}%</span>
|
|
||||||
</li>
|
|
||||||
{% if alcfree_top %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">{{ alcfree_top.label }}</span>
|
|
||||||
<span class="hist-what">{{ alcfree_top.username }}</span>
|
|
||||||
<span class="hist-price">{{ alcfree_top.amount }} Flaschen</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if radler_top %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">{{ radler_top.label }}</span>
|
|
||||||
<span class="hist-what">{{ radler_top.username }}</span>
|
|
||||||
<span class="hist-price">{{ radler_top.amount }} Flaschen</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if water_top %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">{{ water_top.label }}</span>
|
|
||||||
<span class="hist-what">{{ water_top.username }}</span>
|
|
||||||
<span class="hist-price">{{ water_top.amount }} Flaschen</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Fun Facts: Zeit</h3>
|
|
||||||
<ul class="history">
|
|
||||||
{% if first_cons %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Erste Buchung</span>
|
|
||||||
<span class="hist-what">{{ first_cons.user.username }} – {{ first_cons.drink.name }}</span>
|
|
||||||
<span class="hist-price">{{ first_cons.created_at|date:"d.m. H:i" }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if last_cons %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Letzter Schluck</span>
|
|
||||||
<span class="hist-what">{{ last_cons.user.username }} – {{ last_cons.drink.name }}</span>
|
|
||||||
<span class="hist-price">{{ last_cons.created_at|date:"d.m. H:i" }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if earliest_per_user %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Frühaufsteher</span>
|
|
||||||
<span class="hist-what">{{ earliest_per_user.user__username }}</span>
|
|
||||||
<span class="hist-price">{{ earliest_per_user.t|date:"d.m. H:i" }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if latest_per_user %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Nachtschwärmer</span>
|
|
||||||
<span class="hist-what">{{ latest_per_user.user__username }}</span>
|
|
||||||
<span class="hist-price">{{ latest_per_user.t|date:"d.m. H:i" }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if golden_hour %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Goldene Stunde</span>
|
|
||||||
<span class="hist-what">{{ golden_hour.label }}</span>
|
|
||||||
<span class="hist-price">{{ golden_hour.amount }} Flaschen</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if busiest_day %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Härtester Tag</span>
|
|
||||||
<span class="hist-what">{{ busiest_day.label }}</span>
|
|
||||||
<span class="hist-price">{{ busiest_day.amount }} Flaschen</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Fun Facts: Geld</h3>
|
|
||||||
<ul class="history">
|
|
||||||
{% if biggest_tip %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Bigspender-Trinkgeld</span>
|
|
||||||
<span class="hist-what">{{ biggest_tip.username }}</span>
|
|
||||||
<span class="hist-price">{{ biggest_tip.tip|euro }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Eintrittsspenden gesamt</span>
|
|
||||||
<span class="hist-what">aus Überzahlungen</span>
|
|
||||||
<span class="hist-price">{{ total_entry|euro }}</span>
|
|
||||||
</li>
|
|
||||||
{% for method, sum in method_split.items %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Zahlungen {{ method }}</span>
|
|
||||||
<span class="hist-what"></span>
|
|
||||||
<span class="hist-price">{{ sum|euro }}</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Fun Facts: Gratis</h3>
|
|
||||||
<ul class="history">
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Aufmerksamkeit des Hauses</span>
|
|
||||||
<span class="hist-what">Gratis-Flaschen</span>
|
|
||||||
<span class="hist-price">{{ free_total_count }}x</span>
|
|
||||||
</li>
|
|
||||||
{% if top_free_recipient %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">Glücklicher Empfänger</span>
|
|
||||||
<span class="hist-what">{{ top_free_recipient.user__username }}</span>
|
|
||||||
<span class="hist-price">{{ top_free_recipient.amount }} gratis</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="logout-form">
|
|
||||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
{% extends "suff/base.html" %}
|
{% extends "suff/base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Hallo <span class="self-username">{{ tab_user.username }}</span></h2>
|
<h2>Hallo {{ tab_user.username }}</h2>
|
||||||
|
|
||||||
<p class="intro">
|
{% if phase == "readonly" %}
|
||||||
Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
|
<p><i>Festival vorbei. Buchungen geschlossen, nur noch Anzeige.</i></p>
|
||||||
alles zusammen mit deiner <strong>Eintrittsspende</strong> bezahlen –
|
{% endif %}
|
||||||
bar oder per PayPal.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if booked_drink %}
|
{% if booked_drink %}
|
||||||
<div class="toast" role="status">
|
<div class="toast" role="status">
|
||||||
@@ -15,78 +13,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if paid_toast %}
|
<section class="total-box">
|
||||||
<div class="toast" role="status">
|
<span class="total-label">Deine Rechnung</span>
|
||||||
Zahlung gespeichert. Danke!
|
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
|
||||||
{% if paid and open_balance <= 0 %}
|
|
||||||
<span class="total-settled">Bezahlt ✓</span>
|
|
||||||
<div class="total-breakdown">
|
|
||||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
{% if open_balance < 0 %}
|
|
||||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
|
||||||
{% else %}
|
|
||||||
<span>Genau bezahlt</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="total-label">Deine Rechnung</span>
|
|
||||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
|
||||||
{% if paid %}
|
|
||||||
<span class="total-label">Bezahlt</span>
|
|
||||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
|
||||||
<span class="total-label">Offen</span>
|
|
||||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Für Drinks und Eintritt spenden</a></p>
|
|
||||||
|
|
||||||
{% if phase == "booking" %}
|
{% if phase == "booking" %}
|
||||||
<section>
|
<section>
|
||||||
<h3>Neues Getränk buchen</h3>
|
<h3>Neues Getränk buchen</h3>
|
||||||
<form method="post" action="{% url 'suff:book' %}">
|
<div class="drink-grid">
|
||||||
{% csrf_token %}
|
{% for drink in drinks %}
|
||||||
<label class="for-free-toggle">
|
<form method="post" action="{% url 'suff:book' %}">
|
||||||
<input type="checkbox" name="booking_mode" value="for_free" />
|
{% csrf_token %}
|
||||||
<span>Gratis (z.B. Artists am Spieltag)</span>
|
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
|
||||||
</label>
|
<button type="submit" class="drink-btn">
|
||||||
<label class="for-free-toggle">
|
<span class="drink-plus">+1</span>
|
||||||
<input type="checkbox" name="booking_mode" value="cash_paid" />
|
<span class="drink-name">{{ drink.name }}</span>
|
||||||
<span>Direkt bar bezahlt</span>
|
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
||||||
</label>
|
</button>
|
||||||
<script>
|
</form>
|
||||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(cb) {
|
|
||||||
cb.addEventListener('change', function() {
|
|
||||||
if (this.checked) {
|
|
||||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(other) {
|
|
||||||
if (other !== cb) other.checked = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% regroup drinks by alcohol_label as drink_groups %}
|
|
||||||
{% for group in drink_groups %}
|
|
||||||
<h4 class="drink-group-heading">{{ group.grouper }}</h4>
|
|
||||||
<div class="drink-grid">
|
|
||||||
{% for drink in group.list %}
|
|
||||||
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
|
|
||||||
<span class="drink-plus">+1</span>
|
|
||||||
<span class="drink-name">{{ drink.name }}</span>
|
|
||||||
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
|
||||||
</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</form>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -105,9 +52,6 @@
|
|||||||
<span class="hist-price">
|
<span class="hist-price">
|
||||||
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
|
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% if phase == "booking" %}
|
|
||||||
<a href="{% url 'suff:delete_consumption' c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -117,22 +61,13 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p class="empty-emoji">🍺</p>
|
<p class="empty-emoji">🍺</p>
|
||||||
<p>Noch nichts gebucht.</p>
|
<p>Noch nichts gebucht.</p>
|
||||||
|
{% if phase == "booking" %}<p class="muted">Tipp dich rein, sobald du was trinkst!</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if request.user.is_staff %}
|
<form method="post" action="{% url 'suff:logout' %}" class="logout-form">
|
||||||
<div class="link-row">
|
{% csrf_token %}
|
||||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Crew: Buchen für andere</a>
|
<button type="submit" class="btn-secondary">Logout</button>
|
||||||
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Crew: Dashboard</a>
|
</form>
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="bottom-actions">
|
|
||||||
<a href="{% url 'suff:change_pin' %}" class="btn-secondary btn-secondary-link">PIN ändern</a>
|
|
||||||
<form method="post" action="{% url 'suff:logout' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn-secondary">Logout</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
{% extends "suff/base.html" %}
|
{% extends "suff/base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>PIN benötigt</h2>
|
<h2>Keine PIN gesetzt</h2>
|
||||||
<p>
|
<p>
|
||||||
Für den Namen <b>{{ username }}</b> ist keine PIN gesetzt, aber
|
Für den Namen <b>{{ username }}</b> ist noch keine PIN hinterlegt.
|
||||||
der Account hat schon Buchungen oder Zahlungen.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Aus Sicherheit kannst du diesen Account nicht selbst übernehmen.
|
Das ist ein Staff-Account. Aus Sicherheitsgründen kann die PIN für solche
|
||||||
Bitte jemanden an der Bar, dir eine neue PIN zu setzen.
|
Accounts nicht selbst gesetzt werden – sonst könnte sich jeder mit dem
|
||||||
|
Namen eines Admins eine eigene PIN anlegen und damit hier einloggen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Bitte einen Admin bitten, die PIN über das Admin-Panel zu setzen.
|
||||||
</p>
|
</p>
|
||||||
<div class="link-row">
|
<div class="link-row">
|
||||||
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
|
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
|
||||||
|
<a href="/admin/" class="link-btn link-btn-secondary">Admin-Panel</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h2>Party vorbei!</h2>
|
|
||||||
<p>
|
|
||||||
Der Name <b>{{ username }}</b> existiert noch nicht —
|
|
||||||
neue Accounts können nicht mehr angelegt werden, weil das Festival vorbei ist.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Du hast schon einen Account? Gib deinen Namen nochmal ein.
|
|
||||||
</p>
|
|
||||||
<div class="link-row">
|
|
||||||
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
{% load l10n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h2>Spenden</h2>
|
|
||||||
|
|
||||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
|
||||||
{% if paid and open_balance <= 0 %}
|
|
||||||
<span class="total-settled">Bezahlt ✓</span>
|
|
||||||
<div class="total-breakdown">
|
|
||||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
{% if open_balance < 0 %}
|
|
||||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
|
||||||
{% else %}
|
|
||||||
<span>Genau bezahlt</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="total-label">Deine Rechnung</span>
|
|
||||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
|
||||||
{% if paid %}
|
|
||||||
<span class="total-label">Bezahlt</span>
|
|
||||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="total-label">Offen (Drinks)</span>
|
|
||||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p class="intro">
|
|
||||||
Dein Beitrag deckt die <strong>Drinks</strong> und deine
|
|
||||||
<strong>Eintrittsspende</strong>. Die Vorschläge unten runden deinen
|
|
||||||
offenen Drink-Betrag auf die nächsten 5 € und legen 10–30 €
|
|
||||||
Eintritt drauf.
|
|
||||||
</p>
|
|
||||||
<p class="intro">
|
|
||||||
Du darfst gerne <strong>weniger</strong> geben, wenn das gerade besser
|
|
||||||
passt – kein Problem. Und wenn du <strong>mehr</strong> geben kannst,
|
|
||||||
hilft uns das sehr, die Kosten für Bands, Toiletten usw. zu
|
|
||||||
decken. So oder so: <strong>danke, dass du da bist!</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Vorschläge</h3>
|
|
||||||
<div class="amount-grid">
|
|
||||||
{% for s in suggestions %}
|
|
||||||
<a href="?amount={{ s.amount }}" class="amount-btn{% if prefill_amount == s.amount %} amount-btn-active{% endif %}">
|
|
||||||
<span class="amount-main">{{ s.amount }} €</span>
|
|
||||||
<span class="amount-sub">→ {{ s.entry|floatformat:2 }} € Eintritt</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
{% if open_balance > 0 %}
|
|
||||||
<a href="?amount={{ open_balance_url }}" class="amount-btn amount-btn-weak">
|
|
||||||
<span class="amount-main">{{ open_balance|floatformat:2 }} €</span>
|
|
||||||
<span class="amount-sub">→ Nur Drinks</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<p class="error">{{ error }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:pay' %}" class="pay-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label>
|
|
||||||
Betrag (€)
|
|
||||||
<input type="text" name="amount" inputmode="decimal" pattern="[0-9]+([.,][0-9]{1,2})?"
|
|
||||||
value="{% if prefill_value %}{{ prefill_value }}{% else %}{{ suggestions.0.amount }}{% endif %}" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Methode
|
|
||||||
<select name="method" required>
|
|
||||||
<option value="">— wählen —</option>
|
|
||||||
{% for value, label in payment_methods %}
|
|
||||||
<option value="{{ value }}">{{ label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Notiz (optional)
|
|
||||||
<input type="text" name="note" maxlength="64" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn-primary">Zahlung eintragen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="link-row">
|
|
||||||
<a href="https://www.paypal.com/paypalme/lotharwiener" target="_blank" rel="noopener noreferrer" class="link-btn link-btn-paypal">
|
|
||||||
PayPal öffnen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if user_payments %}
|
|
||||||
<section>
|
|
||||||
<h3>Bisherige Zahlungen</h3>
|
|
||||||
<ul class="history">
|
|
||||||
{% for p in user_payments %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
|
|
||||||
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %}</span>
|
|
||||||
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
|
|
||||||
{% if phase == "booking" %}
|
|
||||||
<a href="{% url 'suff:delete_payment' p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="logout-form">
|
|
||||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -5,15 +5,12 @@
|
|||||||
<h2>Neuer Account: {{ username }}</h2>
|
<h2>Neuer Account: {{ username }}</h2>
|
||||||
<p>Merk dir diesen Namen: <b>{{ username }}</b>. Du brauchst ihn beim nächsten Login.</p>
|
<p>Merk dir diesen Namen: <b>{{ username }}</b>. Du brauchst ihn beim nächsten Login.</p>
|
||||||
<p>Wähl eine 3-stellige PIN.</p>
|
<p>Wähl eine 3-stellige PIN.</p>
|
||||||
{% elif mode == "claim" %}
|
|
||||||
<h2>Account übernehmen: {{ username }}</h2>
|
|
||||||
<p>Dieser Account hat noch keine PIN und keine Buchungen. Setz jetzt eine 3-stellige PIN, um ihn zu übernehmen.</p>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2>Hallo {{ username }}</h2>
|
<h2>Hallo {{ username }}</h2>
|
||||||
<p>Gib deine 3-stellige PIN ein.</p>
|
<p>Gib deine 3-stellige PIN ein.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
{% if error %}<p><b>{{ error }}</b></p>{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:pin' %}">
|
<form method="post" action="{% url 'suff:pin' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -23,7 +20,7 @@
|
|||||||
minlength="3" required autofocus autocomplete="off" />
|
minlength="3" required autofocus autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
{% if mode == "create" %}Account anlegen{% elif mode == "claim" %}Account übernehmen{% else %}Login{% endif %}
|
{% if mode == "create" %}Account anlegen{% else %}Login{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="muted">Crew-Ansicht</p>
|
|
||||||
<h2>Buchung von <span class="staff-target">{{ tab_user.username }}</span> löschen?</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Willst du wirklich
|
|
||||||
<strong>{{ consumption.drink.name }}</strong>
|
|
||||||
({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %})
|
|
||||||
von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %}
|
|
||||||
löschen?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if tab_user.username == "anonym" %}
|
|
||||||
<p class="muted">Die zugehörige Bar-Zahlung wird ebenfalls entfernt.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:staff_delete_consumption' tab_user.username consumption.id %}" class="confirm-actions">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
|
||||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="muted">Crew-Ansicht</p>
|
|
||||||
<h2>Zahlung löschen für <span class="staff-target">{{ tab_user.username }}</span>?</h2>
|
|
||||||
|
|
||||||
<section class="total-box">
|
|
||||||
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} – {{ payment.note }}{% endif %}</span>
|
|
||||||
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
|
|
||||||
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:staff_delete_payment' tab_user.username payment.id %}" class="confirm-actions">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
|
||||||
<a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="muted">Crew-Ansicht</p>
|
|
||||||
<h2>Benutzer wählen</h2>
|
|
||||||
|
|
||||||
<p>Buchung oder Zahlung im Auftrag eines Benutzers eintragen.</p>
|
|
||||||
|
|
||||||
<div class="link-row">
|
|
||||||
<a href="{% url 'suff:staff_register' %}" class="link-btn">Neuen Benutzer anlegen</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if anonymous %}
|
|
||||||
<div class="link-row">
|
|
||||||
<a href="{% url 'suff:staff_user' anonymous.username %}" class="link-btn">
|
|
||||||
Anonymer Gast (Bar)
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Registrierte Benutzer</h3>
|
|
||||||
{% if users %}
|
|
||||||
<ul class="history">
|
|
||||||
{% for u in users %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-what">
|
|
||||||
{% if u.username == request.user.username %}
|
|
||||||
<span class="user-self">{{ u.username }} <span class="muted-inline">(das bist du)</span></span>
|
|
||||||
{% else %}
|
|
||||||
<a href="{% url 'suff:staff_user' u.username %}">{{ u.username }}</a>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">Keine Benutzer.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="logout-form">
|
|
||||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="muted">Crew-Ansicht</p>
|
|
||||||
<h2>Zahlung für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
|
||||||
|
|
||||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
|
||||||
{% if paid and open_balance <= 0 %}
|
|
||||||
<span class="total-settled">Bezahlt ✓</span>
|
|
||||||
<div class="total-breakdown">
|
|
||||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
{% if open_balance < 0 %}
|
|
||||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
|
||||||
{% else %}
|
|
||||||
<span>Genau bezahlt</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="total-label">Rechnung</span>
|
|
||||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
|
||||||
{% if paid %}
|
|
||||||
<span class="total-label">Bezahlt</span>
|
|
||||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="total-label">Offen</span>
|
|
||||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<p class="error">{{ error }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:staff_pay' tab_user.username %}" class="pay-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label>
|
|
||||||
Betrag (€)
|
|
||||||
<input type="number" name="amount" step="0.01" min="0.01"
|
|
||||||
value="{% if open_balance > 0 %}{{ open_balance|floatformat:2 }}{% else %}0.00{% endif %}" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Methode
|
|
||||||
<select name="method" required>
|
|
||||||
<option value="">— wählen —</option>
|
|
||||||
{% for value, label in payment_methods %}
|
|
||||||
<option value="{{ value }}">{{ label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Notiz (optional)
|
|
||||||
<input type="text" name="note" maxlength="64" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn-primary">Zahlung eintragen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if user_payments %}
|
|
||||||
<section>
|
|
||||||
<h3>Bisherige Zahlungen</h3>
|
|
||||||
<ul class="history">
|
|
||||||
{% for p in user_payments %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
|
|
||||||
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %}</span>
|
|
||||||
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
|
|
||||||
{% if phase == "booking" %}
|
|
||||||
<a href="{% url 'suff:staff_delete_payment' tab_user.username p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="logout-form">
|
|
||||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="muted">Crew-Ansicht</p>
|
|
||||||
<h2>PIN setzen für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
|
||||||
|
|
||||||
<p>Neue 3-stellige PIN eingeben. Eine bestehende PIN wird überschrieben.</p>
|
|
||||||
|
|
||||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:staff_pin_reset' tab_user.username %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label>
|
|
||||||
Neue PIN
|
|
||||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
|
|
||||||
maxlength="3" minlength="3" required autofocus autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn-primary">PIN speichern</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="logout-form">
|
|
||||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="muted">Crew-Ansicht</p>
|
|
||||||
<h2>Neuen Benutzer anlegen</h2>
|
|
||||||
|
|
||||||
<p>Name eingeben. PIN ist optional — kann später bei Bedarf gesetzt werden.</p>
|
|
||||||
|
|
||||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:staff_register' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input type="text" name="name" maxlength="150" required autofocus
|
|
||||||
value="{{ prefill_name }}" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
PIN (optional, 3 Ziffern)
|
|
||||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
|
|
||||||
maxlength="3" autocomplete="off" value="{{ prefill_pin }}" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn-primary">Anlegen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="logout-form">
|
|
||||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Zurück</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
{% extends "suff/base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="muted">Crew-Ansicht</p>
|
|
||||||
<h2>Buchen für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
|
||||||
|
|
||||||
{% if booked_drink %}
|
|
||||||
<div class="toast" role="status">
|
|
||||||
Gebucht: +1 {{ booked_drink.name }}{% if booked_free %} (gratis){% elif is_anonymous_target %} (bar bezahlt){% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if paid_toast %}
|
|
||||||
<div class="toast" role="status">
|
|
||||||
Zahlung gespeichert.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if is_anonymous_target %}
|
|
||||||
<p class="muted">Anonyme Buchungen werden automatisch als bar bezahlt eingetragen.</p>
|
|
||||||
{% else %}
|
|
||||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
|
||||||
{% if paid and open_balance <= 0 %}
|
|
||||||
<span class="total-settled">Bezahlt ✓</span>
|
|
||||||
<div class="total-breakdown">
|
|
||||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
|
||||||
<span>·</span>
|
|
||||||
{% if open_balance < 0 %}
|
|
||||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
|
||||||
{% else %}
|
|
||||||
<span>Genau bezahlt</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="total-label">Rechnung</span>
|
|
||||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
|
||||||
{% if paid %}
|
|
||||||
<span class="total-label">Bezahlt</span>
|
|
||||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
|
||||||
<span class="total-label">Offen</span>
|
|
||||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p><a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-primary">Zahlung eintragen</a></p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if phase == "booking" %}
|
|
||||||
<section>
|
|
||||||
<h3>Getränk buchen</h3>
|
|
||||||
<form method="post" action="{% url 'suff:staff_book' tab_user.username %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label class="for-free-toggle">
|
|
||||||
<input type="checkbox" name="booking_mode" value="for_free" />
|
|
||||||
<span>Gratis (z.B. Artists am Spieltag)</span>
|
|
||||||
</label>
|
|
||||||
{% if not is_anonymous_target %}
|
|
||||||
<label class="for-free-toggle">
|
|
||||||
<input type="checkbox" name="booking_mode" value="cash_paid" />
|
|
||||||
<span>Direkt bar bezahlt</span>
|
|
||||||
</label>
|
|
||||||
{% endif %}
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(cb) {
|
|
||||||
cb.addEventListener('change', function() {
|
|
||||||
if (this.checked) {
|
|
||||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(other) {
|
|
||||||
if (other !== cb) other.checked = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% regroup drinks by alcohol_label as drink_groups %}
|
|
||||||
{% for group in drink_groups %}
|
|
||||||
<h4 class="drink-group-heading">{{ group.grouper }}</h4>
|
|
||||||
<div class="drink-grid">
|
|
||||||
{% for drink in group.list %}
|
|
||||||
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
|
|
||||||
<span class="drink-plus">+1</span>
|
|
||||||
<span class="drink-name">{{ drink.name }}</span>
|
|
||||||
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
|
||||||
</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Bisher gebucht</h3>
|
|
||||||
{% if consumption_list %}
|
|
||||||
{% regroup consumption_list by get_day_display as day_groups %}
|
|
||||||
{% for group in day_groups %}
|
|
||||||
<div class="day-group">
|
|
||||||
<h4 class="day-heading">{{ group.grouper }}</h4>
|
|
||||||
<ul class="history">
|
|
||||||
{% for c in group.list %}
|
|
||||||
<li>
|
|
||||||
<span class="hist-when">{% if c.created_at %}{{ c.created_at|date:"H:i" }}{% else %}—{% endif %}</span>
|
|
||||||
<span class="hist-what">{{ c.drink.name }}</span>
|
|
||||||
<span class="hist-price">
|
|
||||||
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
|
|
||||||
</span>
|
|
||||||
{% if phase == "booking" %}
|
|
||||||
<a href="{% url 'suff:staff_delete_consumption' tab_user.username c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<p class="empty-emoji">🍺</p>
|
|
||||||
<p>Noch nichts gebucht.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="link-row">
|
|
||||||
{% if not is_anonymous_target %}
|
|
||||||
<a href="{% url 'suff:staff_pin_reset' tab_user.username %}" class="link-btn link-btn-secondary">PIN setzen / zurücksetzen</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Anderen Benutzer wählen</a>
|
|
||||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück zu mir</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -94,8 +94,7 @@ TEMPLATES = [
|
|||||||
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
|
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Berlin"
|
TIME_ZONE = "Europe/Berlin"
|
||||||
LANGUAGE_CODE = "de"
|
USE_I18N = False
|
||||||
USE_I18N = True
|
|
||||||
USE_L10N = False
|
USE_L10N = False
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -3,14 +3,14 @@ name = "gaehsnitz"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django==6.0.6",
|
"django==6.0.5",
|
||||||
"gunicorn==26.0.0",
|
"gunicorn==26.0.0",
|
||||||
"psycopg[binary]==3.3.4",
|
"psycopg[binary]==3.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.16",
|
"ruff==0.15.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
@@ -1,105 +1,64 @@
|
|||||||
# Suff – drink booking tool
|
# Suff – drink booking tool
|
||||||
|
|
||||||
Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS.
|
Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS, no CSS yet.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
- `User.pin` (hashed CharField, 3 digits), separate from `password`. Strong password stays for `/admin/`.
|
- `User.pin` field (hashed CharField) stores 3-digit PIN, separate from `password`. Lets staff keep a strong password for `/admin/` and use the same username on `/suff/` with just a PIN.
|
||||||
- `PinBackend` authenticates by `username` + `pin`. `ModelBackend` first in `AUTHENTICATION_BACKENDS` so admin still needs strong password.
|
- `PinBackend` (`gaehsnitz/auth_backends.py`) authenticates by `username` + `pin` via `user.check_pin()`. Default `ModelBackend` stays first in `AUTHENTICATION_BACKENDS` so `/admin/` keeps requiring the strong password.
|
||||||
- PIN reset is **crew-only**. No self-reset, no random PINs, PINs never displayed.
|
- Staff PINs cannot be self-set on `/suff/`. If a name matches an existing user with no PIN, the user lands on `suff/no_pin.html` explaining that an admin must set the PIN via the admin panel — otherwise anyone could claim a staff name and lock out the real owner.
|
||||||
|
- Admin convenience: User change page shows PIN status ("gesetzt"/"nicht gesetzt") + "PIN setzen" link → custom admin view `<id>/pin/` with a 3-digit form, calls `user.set_pin()`.
|
||||||
|
|
||||||
## Name flow
|
## Name flow
|
||||||
|
|
||||||
Username = `slugify(input)`. POST name:
|
- Username = `slugify(input)` (e.g. "Flo Hä!" → "flo-ha"). Slug shown back so user can memorize it.
|
||||||
|
- POST name → check existence:
|
||||||
- not found → `create` mode → mandatory 3-digit PIN → create user → login
|
- not found → set new PIN → create user → login
|
||||||
- found, has PIN → `login` mode → enter PIN
|
- found, has PIN → enter PIN → login
|
||||||
- found, no PIN, **no activity** (no Consumption + no UserPayment) → `claim` mode → set PIN → login
|
- found, no PIN → `no_pin.html` (ask admin)
|
||||||
- found, no PIN, **has activity** → `no_pin.html` ("ask someone at the bar")
|
|
||||||
|
|
||||||
## Booking
|
## Booking
|
||||||
|
|
||||||
- `/suff/me/` shows: greeting, total/paid/open balance, drink grid, day-grouped history.
|
- `/suff/me/` shows: greeting (slug), running paid total, full consumption history with timestamps, drink buttons.
|
||||||
- Single form wraps two radio buttons (`booking_mode`) + all drink buttons (`name="drink_id"`).
|
- Each drink = `+1` POST form. Server creates `Consumption(amount=1, day=current_weekday, for_free=False, created_at=auto)`.
|
||||||
- `booking_mode` values: `normal` (default, no radio selected), `for_free`, `cash_paid`.
|
- No undo, no delete, no edit. No special bartender role.
|
||||||
- `+1` POST creates `Consumption(amount=1, day=current_weekday, for_free=...)`.
|
- History sorted newest-first, `created_at` shown as `Do 18:42` etc.
|
||||||
- `cash_paid` booking auto-creates matching `UserPayment(method=cash)` — same as anonymous walk-ins.
|
|
||||||
- Trash icon per row → `confirm_delete.html` → POST deletes own consumption (booking phase only).
|
|
||||||
- History grouped by festival day, newest-first per day.
|
|
||||||
|
|
||||||
## Payments
|
|
||||||
|
|
||||||
- `/suff/pay/` — user enters amount + method (cash/paypal/bank/other) + optional note. Creates `UserPayment`. Pre-fills with current `open_balance`.
|
|
||||||
- Method choices: `UserPayment.Method`.
|
|
||||||
- Open balance = sum(Consumption.price where !for_free) − sum(UserPayment.amount).
|
|
||||||
- Balance panel shows settled state (green "Bezahlt ✓" + breakdown) when `open_balance <= 0`; amber with "Offen X €" otherwise. Applied on `/suff/me/`, `/suff/pay/`, `/suff/staff/u/<name>/`, `/suff/staff/u/<name>/pay/`.
|
|
||||||
|
|
||||||
## Crew (`is_staff`)
|
|
||||||
|
|
||||||
Separate page tree under `/suff/staff/`:
|
|
||||||
|
|
||||||
- `/suff/staff/` — alphabetical user list, anonymous gast on top, "Neuen Benutzer anlegen" link.
|
|
||||||
- `/suff/staff/new/` — register user. Name required, PIN optional 3 digits.
|
|
||||||
- `/suff/staff/u/<name>/` — book/pay/delete for that user. Mirrors `me.html`. "Zahlung eintragen" link always visible.
|
|
||||||
- `/suff/staff/u/<name>/pin/` — overwrite PIN (3 digits required, no clear).
|
|
||||||
- `/suff/staff/u/<name>/pay/` — record payment for that user. Amount pre-fills with `max(open_balance, 0)`.
|
|
||||||
- `/suff/staff/u/<name>/book/<id>/delete/` — delete consumption (and matching auto-payment if anon).
|
|
||||||
|
|
||||||
Staff booking form has same `booking_mode` radios (normal / for_free / cash_paid) as user view.
|
|
||||||
|
|
||||||
Target username highlighted via `.staff-target` (cyan pill) on every crew page.
|
|
||||||
|
|
||||||
## Anonymous walk-ins
|
|
||||||
|
|
||||||
- Seeded user `anonym` (migration 0009). No PIN, never logs in.
|
|
||||||
- Crew books for anonymous via staff_user page → drink booking auto-creates matching `UserPayment(method=cash, note="Auto: <drink>")` so balance always 0.
|
|
||||||
- Deleting an anonymous consumption removes one matching auto-payment.
|
|
||||||
- Anonymous has no pay page (404). PayPal walk-ins → register a real user instead.
|
|
||||||
|
|
||||||
## Time gating (Berlin tz)
|
## Time gating (Berlin tz)
|
||||||
|
|
||||||
- Phases: `before` / `booking` / `closed`.
|
- Phases: `before` / `booking` / `readonly` / `closed`.
|
||||||
- Test window: 2026-05-15 → 2026-05-31. Original festival: 2026-06-11 → 2026-06-14.
|
- Booking allowed Thu 2026-06-11 00:00 → Sun 2026-06-14 23:59.
|
||||||
- `closed` shows static page; outside booking, all action endpoints redirect or 404.
|
- Read-only until Sun 2026-06-21 23:59.
|
||||||
- `settings.PRODUCTION=False` forces `booking`.
|
- After: every `/suff/` URL returns 404.
|
||||||
|
- Local dev: `settings.PRODUCTION=False` forces `booking` phase always.
|
||||||
## Dashboard
|
|
||||||
|
|
||||||
- `/suff/dashboard/` (staff only). Donations vs. expenses with progress bar, drink inventory rows, refinance %, per-user open balances, top spender, top drink, busiest day, top drinker per day.
|
|
||||||
- Finance section includes "Kasse (bar)" — sum of all `UserPayment(method=cash)` — for cross-checking real cash in the box.
|
|
||||||
|
|
||||||
## Drink categories
|
|
||||||
|
|
||||||
- `Drink.category`: beer / alc_free_beer / radler / alc_free_radler / soft / water.
|
|
||||||
- Buttons gradient-colored per category. Sorted by category in grid.
|
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `gaehsnitz/auth_backends.py` — `PinBackend`
|
- `gaehsnitz/auth_backends.py` — `PinBackend`
|
||||||
- `gaehsnitz/suff.py` — all suff views + phase + crew helpers
|
- `gaehsnitz/suff.py` — views + phase logic
|
||||||
- `gaehsnitz/suff_urls.py` — routes
|
- `gaehsnitz/suff_urls.py` — routes
|
||||||
- `gaehsnitz/admin.py` — `SetPinForm` + `set_pin_view`
|
- `gaehsnitz/admin.py` — `SetPinForm` + `set_pin_view`
|
||||||
- `gaehsnitz/templates/suff/{base,name,pin,no_pin,me,pay,dashboard,closed,confirm_delete,staff_index,staff_user,staff_pay,staff_register,staff_pin_reset,staff_confirm_delete}.html`
|
- `gaehsnitz/templates/suff/{base,name,pin,no_pin,me}.html`
|
||||||
|
- `gaehsnitz/templates/admin/gaehsnitz/user/set_pin.html`
|
||||||
- `gaehsnitz/static/suff/{style.css,favicon.svg}`
|
- `gaehsnitz/static/suff/{style.css,favicon.svg}`
|
||||||
- `gaehsnitz/migrations/0009_anonymous_user.py` — seeds `anonym`
|
- `gaehsnitz/migrations/0003_consumption_created_at_user_pin.py`
|
||||||
|
- Edits: `gaehsnitzproject/settings.py`, `gaehsnitzproject/urls.py`, `gaehsnitz/models.py`
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
Mobile-first dark theme. `#161616` bg, `#EE9933`/`#FFCC77` amber, `#885522` brown borders, `#66ddee` cyan for crew target. Drink buttons gradient-colored per category. Toast banner for booking confirmation. `:active` scale feedback. SVG favicon.
|
Mobile-first styled. Dark theme matching GOA palette (`#161616` bg, `#EE9933`/`#FFCC77` amber accents, `#885522` brown borders). Standalone microsite — no nav to main GOA page.
|
||||||
|
|
||||||
## Open ideas / next session
|
- Landing/login: GOA subhead + big "Suff" wordmark, `name` and `pin` forms with stacked label/input, large tap targets
|
||||||
|
- `me` page: 2-col drink button grid (4:3 aspect), stacked +1 / name / price; bordered total box; day-grouped history with zebra rows; emoji empty-state
|
||||||
|
- Booking confirmation: amber toast, 5s display, then 800ms collapse animation (pure CSS, no JS)
|
||||||
|
- `:active` scale(0.96) feedback on buttons + link-buttons
|
||||||
|
- `no_pin.html` link-buttons styled (primary + secondary)
|
||||||
|
- SVG favicon (🍺)
|
||||||
|
|
||||||
### Pay-on-the-spot / quick-pay-cash
|
## Further ideas
|
||||||
|
|
||||||
Single button on `me` (and crew_user) page: "Offenen Betrag bar bezahlen" → creates `UserPayment(method=cash, amount=open_balance)`. Lets bar crew clear tab in one tap when guest pays cash directly. (Skipped for now, keep in mind.)
|
- Color-code drink buttons (per-drink accent border or bg — Bier amber, Wasser blue, etc.) for fast visual recognition in dim light
|
||||||
|
|
||||||
### Prepay vs. pay-at-end
|
|
||||||
|
|
||||||
Currently a single open balance. Could surface "Prepay 50 €" as a flow vs. "Pay at the end" — same data model, different framing. Maybe a "Vorkasse" preset on pay page.
|
|
||||||
|
|
||||||
### Misc
|
|
||||||
|
|
||||||
- PWA manifest for add-to-homescreen
|
|
||||||
- Drink icons/emoji per type
|
- Drink icons/emoji per type
|
||||||
- Style phase pages (`before` / `closed`)
|
- Style phase pages (`before` / `closed` if non-404)
|
||||||
- Per-user QR for fast crew lookup at the bar
|
- PWA manifest for add-to-homescreen
|
||||||
|
- Donation/free-drink flow if needed (currently admin-only via `for_free`)
|
||||||
|
|||||||
Reference in New Issue
Block a user