Compare commits

..

7 Commits

Author SHA1 Message Date
flo 97085b699b Update dependency ruff to v0.15.14 2026-05-21 17:00:45 +00:00
flo 3e70a756d6 refine closed-page and configure test window 2026-05-15 13:22:11 +02:00
flo b10e434d0c Color-code drink buttons by category
Add a Drink.category field (beer, alc_free_beer, radler, alc_free_radler,
soft, water) and apply per-category gradient backgrounds to the booking
buttons so users can recognize drinks at a glance. Sort buttons by
category, shrink them to a 3:2 aspect ratio, and switch labels to more
verbose brand names (Sternburg Export, Ur-Krostitzer, etc.).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:56:27 +02:00
flo 2b46c7cd54 Remove drink_stats / total_balance / user_stats commands
All three are now superseded by the staff-only /suff/dashboard/ page
plus the year-scoped Drink and User admin views: drink_stats by the
Drink list+detail, user_stats by the User list+detail breakdown, and
total_balance by the dashboard's Refinanzierung gesamt panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:18:57 +02:00
flo 2056d5bbc7 Add staff dashboard, payment flow, seed extra 2026 expenses
Self-service /suff/pay/ page lets users record their own payments
(cash/PayPal/bank/other) against their tab. Open balance is shown on
/suff/me/ alongside total and paid amount, with a Bezahlen button when
something is owed.

Staff-only /suff/dashboard/ replaces the drink_stats / total_balance /
user_stats CLI commands with a mobile-friendly festival view: overall
refinancing progress bar (Spenden vs. Ausgaben with Bilanz), drinks
refinancing bar (sales revenue vs. purchase cost with profit), per-drink
sold/total/balance, open balances per user, and fun facts (top
spender, top drink, busiest day, and top user per festival day).
Linked from /suff/me/ when the logged-in user is staff.

seed_drinks_2026 also creates the non-drink Payments we already know
about (toilets, drinks/equipment down payment, band fees per stage day),
idempotently keyed on (purpose, date).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:18:57 +02:00
flo b9c62babf1 Show year-scoped balances and breakdowns in admin
User list shows Konsumiert / Bezahlt / Offener Betrag for the current
year. User detail page adds a BILANZ section with per-drink breakdowns
(paid + free) so a quick look at the user page replaces what
user_stats used to print.

Drink list shows crates_purchased, bottles_sold, bottles_remaining,
purchase_price_total, and balance, with a year filter. Detail page
groups all derived values into German sections (Kästen, Flaschen,
Menge, Einkauf, Pfand, Verkauf), replacing the drink_stats command.

UserPayment admin gets a custom YearFilter (created_at__year is not
admin-filterable directly). Donation/Payment get date filters and
search.

Group admin is unregistered (unused in this project).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:18:57 +02:00
flo 4c9d041254 Add year-scoped drink data, UserPayment model, German verbose names
Drink gets a year field (default 2024 for legacy rows), with name+year
unique together so each festival can have its own price/crate config
without overwriting the previous year. User balance properties
(consumed_drinks_price, paid_amount, open_balance) and the new
current_year() helper all filter by current year so year-over-year data
stays separated.

UserPayment model tracks per-user payments (cash/PayPal/bank/other)
against their drink tab, separate from Donation (event-level income).

Verbose names + Meta verbose_name(_plural) added across all models, and
LANGUAGE_CODE set to "de" so the admin renders German throughout.

Drink gains a few derived properties (crates_full_returned,
crates_remaining, bottles_consumed, bottles_remaining,
remaining_purchase_value) used by the upcoming admin and dashboard
views.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:18:57 +02:00
20 changed files with 1212 additions and 152 deletions
+177 -15
View File
@@ -3,13 +3,18 @@ from django.contrib import admin, messages
from django.contrib.admin.utils import unquote
from django.contrib.auth import get_user_model
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.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from gaehsnitz.models import Donation, Payment, Drink, Consumption
from django.db.models import Sum
from gaehsnitz.models import Donation, Payment, Drink, Consumption, UserPayment, current_year
from gaehsnitz.templatetags.money import euro
User = get_user_model()
@@ -20,6 +25,12 @@ class ConsumptionInline(admin.TabularInline):
extra = 0
class UserPaymentInline(admin.TabularInline):
model = UserPayment
extra = 0
readonly_fields = ("created_at",)
class SetPinForm(forms.Form):
pin = forms.CharField(
label="Neue PIN (3 Ziffern)",
@@ -36,7 +47,7 @@ class SetPinForm(forms.Form):
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ("username", "consumed_drinks_price")
list_display = ("username", "consumed_drinks_price", "paid_amount", "open_balance")
ordering = ("username",)
list_filter = []
fieldsets = (
@@ -50,14 +61,62 @@ class CustomUserAdmin(UserAdmin):
)
},
),
("BALANCE", {"fields": ("consumed_drinks_price",)}),
(
"BILANZ",
{
"fields": (
"consumed_drinks_price",
"paid_amount",
"open_balance",
"drinks_breakdown",
"free_drinks_breakdown",
)
},
),
)
readonly_fields = ("consumed_drinks_price", "pin_status")
inlines = (ConsumptionInline,)
readonly_fields = (
"consumed_drinks_price",
"paid_amount",
"open_balance",
"pin_status",
"drinks_breakdown",
"free_drinks_breakdown",
)
inlines = (UserPaymentInline, ConsumptionInline)
@admin.display(description="Konsumiert")
def consumed_drinks_price(self, user: User):
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")
def pin_status(self, user: User):
status = "gesetzt" if user.pin else "nicht gesetzt"
@@ -104,10 +163,39 @@ class CustomUserAdmin(UserAdmin):
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)
class DonationAdmin(admin.ModelAdmin):
list_display = ("date", "amount", "note")
list_filter = ("date",)
ordering = ("-date",)
search_fields = ("note",)
@admin.display(ordering="amount")
def amount(self, donation: Donation):
@@ -117,7 +205,9 @@ class DonationAdmin(admin.ModelAdmin):
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ("date", "purpose", "amount")
list_filter = ("date",)
ordering = ("-date",)
search_fields = ("purpose",)
@admin.display(ordering="amount")
def amount(self, payment: Payment):
@@ -126,31 +216,46 @@ class PaymentAdmin(admin.ModelAdmin):
@admin.register(Drink)
class DrinkAdmin(admin.ModelAdmin):
list_display = ("name", "purchase_price_per_crate", "crates_purchased", "purchase_price_total")
list_display = (
"name",
"year",
"crates_purchased",
"bottles_sold",
"bottles_remaining",
"purchase_price_total",
"balance",
)
list_filter = ("year",)
fieldsets = (
(None, {"fields": ("name",)}),
(None, {"fields": ("name", "year")}),
(
"crates",
"Kästen",
{
"fields": (
"crates_ordered",
"crates_purchased",
"crates_full_returned",
"crates_returned",
"crates_remaining",
)
},
),
(
"bottles",
"Flaschen",
{
"fields": (
"bottles_per_crate",
"bottles_total",
"bottles_returned",
"bottles_sold",
"bottles_given_away",
"bottles_consumed",
"bottles_remaining",
)
},
),
(
"amount",
"Menge",
{
"fields": (
"bottle_size",
@@ -160,17 +265,18 @@ class DrinkAdmin(admin.ModelAdmin):
},
),
(
"purchase",
"Einkauf",
{
"fields": (
"purchase_price_per_crate",
"purchase_price_per_bottle",
"purchase_price_total",
"remaining_purchase_value",
)
},
),
(
"deposit",
"Pfand",
{
"fields": (
"deposit_per_crate",
@@ -181,14 +287,12 @@ class DrinkAdmin(admin.ModelAdmin):
},
),
(
"sales",
"Verkauf",
{
"fields": (
"sale_price_per_bottle",
"bottles_sold",
"sales_purchase_value",
"sale_price_total",
"bottles_given_away",
"giveaway_purchase_value",
"balance",
)
@@ -198,10 +302,15 @@ class DrinkAdmin(admin.ModelAdmin):
readonly_fields = (
"bottles_total",
"bottles_returned",
"bottles_consumed",
"bottles_remaining",
"crates_full_returned",
"crates_remaining",
"amount_per_crate",
"amount_total",
"purchase_price_per_bottle",
"purchase_price_total",
"remaining_purchase_value",
"deposit_total",
"deposit_refund",
"deposit_kept",
@@ -213,29 +322,82 @@ class DrinkAdmin(admin.ModelAdmin):
"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):
return euro(drink.purchase_price_per_bottle)
@admin.display(description="Einkaufspreis gesamt")
def purchase_price_total(self, drink: Drink):
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):
return euro(drink.deposit_total)
@admin.display(description="Pfand zurück")
def deposit_refund(self, drink: Drink):
return euro(drink.deposit_refund)
@admin.display(description="Pfand einbehalten")
def deposit_kept(self, drink: Drink):
return euro(drink.deposit_kept)
@admin.display(description="Einkaufswert verkauft")
def sales_purchase_value(self, drink: Drink):
return euro(drink.sales_purchase_value)
@admin.display(description="Verkaufserlös")
def sale_price_total(self, drink: Drink):
return euro(drink.sale_price_total)
@admin.display(description="Einkaufswert verschenkt")
def giveaway_purchase_value(self, drink: Drink):
return euro(drink.giveaway_purchase_value)
@admin.display(description="Bilanz")
def balance(self, drink: Drink):
return mark_safe(f"<b>{euro(drink.balance)}</b>")
@@ -1,33 +0,0 @@
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)}")
@@ -0,0 +1,65 @@
from datetime import date
from django.core.management import BaseCommand
from django.db import transaction
from gaehsnitz.models import Drink, Payment
PAYMENTS = [
# purpose, date, amount
("Toiletten", date(2026, 5, 6), 210.01),
("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 5, 18), 400.00),
("Band: Six Good Years", date(2026, 6, 12), 150.00),
("Band: Melo-Komplott", date(2026, 6, 12), 100.00),
("Band: Mörtel", date(2026, 6, 12), 150.00),
("Band: Kotpiloten", date(2026, 6, 13), 150.00),
("Band: Knast", date(2026, 6, 13), 150.00),
("Band: Quast", date(2026, 6, 13), 300.00),
]
DRINKS = [
# name, category, crates, btl/crate, size, price/crate, deposit/crate, sale/btl
("Sternburg Export", "beer", 12, 20, 0.5, 10.99, 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, 15.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),
]
class Command(BaseCommand):
help = "Seed Drink rows for the 2026 festival from the supplier invoice."
@transaction.atomic
def handle(self, *args, **options):
for purpose, day, amount in PAYMENTS:
obj, created = Payment.objects.update_or_create(
purpose=purpose,
date=day,
defaults={"amount": amount},
)
self.stdout.write(f"{'created' if created else 'updated'}: {obj.purpose} ({obj.date})")
for name, 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}")
@@ -1,19 +0,0 @@
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,23 +0,0 @@
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)})")
+26
View File
@@ -0,0 +1,26 @@
# 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")},
),
]
@@ -0,0 +1,167 @@
# 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"),
),
]
+51
View File
@@ -0,0 +1,51 @@
# 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",
},
),
]
@@ -0,0 +1,28 @@
# 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",
),
),
]
@@ -0,0 +1,29 @@
# 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",
),
),
]
+131 -26
View File
@@ -2,6 +2,11 @@ from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Sum, F
from django.utils import timezone
def current_year():
return timezone.now().year
class PriceField(models.DecimalField):
@@ -11,7 +16,11 @@ class PriceField(models.DecimalField):
class User(AbstractUser):
pin = models.CharField(max_length=128, blank=True, default="")
pin = models.CharField("PIN", max_length=128, blank=True, default="")
class Meta:
verbose_name = "Benutzer"
verbose_name_plural = "Benutzer"
def set_pin(self, raw_pin):
self.pin = make_password(raw_pin)
@@ -31,16 +40,31 @@ class User(AbstractUser):
@property
def consumed_drinks_price(self):
query = self.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(
sum=Sum("cost")
query = (
self.paid_drinks.filter(drink__year=current_year())
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
.aggregate(sum=Sum("cost"))
)
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):
date = models.DateField()
amount = PriceField()
note = models.CharField(max_length=64, blank=True, default="")
date = models.DateField("Datum")
amount = PriceField("Betrag")
note = models.CharField("Notiz", max_length=64, blank=True, default="")
class Meta:
verbose_name = "Spende"
verbose_name_plural = "Spenden"
class Payment(models.Model):
@@ -49,26 +73,51 @@ class Payment(models.Model):
bands = 11, "Bands"
supply_purchase = 12, "Getränke-/Essenseinkauf"
purpose = models.CharField(max_length=64)
date = models.DateField()
amount = PriceField()
purpose = models.CharField("Zweck", max_length=64)
date = models.DateField("Datum")
amount = PriceField("Betrag")
class Meta:
verbose_name = "Ausgabe"
verbose_name_plural = "Ausgaben"
class Drink(models.Model):
name = models.CharField(max_length=32, unique=True)
crates_ordered = models.PositiveSmallIntegerField(
help_text="just informational to see how good we planned, not the actual consumed/paid drinks"
class Category(models.TextChoices):
beer = "beer", "Bier"
radler = "radler", "Radler"
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_purchased = models.PositiveSmallIntegerField()
crates_returned = models.PositiveSmallIntegerField()
purchase_price_per_crate = PriceField()
deposit_per_crate = PriceField()
bottles_per_crate = models.PositiveSmallIntegerField()
bottle_size = models.FloatField()
sale_price_per_bottle = PriceField()
crates_ordered = models.PositiveSmallIntegerField(
"Kästen bestellt",
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_returned = models.PositiveSmallIntegerField("Kästen leer zurück")
purchase_price_per_crate = PriceField("Einkaufspreis pro Kasten")
deposit_per_crate = PriceField("Pfand pro Kasten")
bottles_per_crate = models.PositiveSmallIntegerField("Flaschen pro Kasten")
bottle_size = models.FloatField("Flaschengröße (l)")
sale_price_per_bottle = PriceField("Verkaufspreis pro Flasche")
class Meta:
unique_together = (("name", "year"),)
verbose_name = "Getränk"
verbose_name_plural = "Getränke"
def __str__(self):
return self.name
return f"{self.name} {self.year}"
@property
def bottles_total(self):
@@ -132,15 +181,71 @@ class Drink(models.Model):
def balance(self):
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):
user = models.ForeignKey(
to=User, on_delete=models.CASCADE, related_name="consumption_list", related_query_name="consumption"
verbose_name="Benutzer",
to=User,
on_delete=models.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
)
drink = models.ForeignKey(
to=Drink, on_delete=models.CASCADE, related_name="consumption_list", related_query_name="consumption"
verbose_name="Getränk",
to=Drink,
on_delete=models.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
)
amount = models.PositiveSmallIntegerField()
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
for_free = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True, null=True)
amount = models.PositiveSmallIntegerField("Anzahl")
day = models.PositiveSmallIntegerField("Tag", choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
for_free = models.BooleanField("Gratis", default=False)
created_at = models.DateTimeField("Gebucht am", auto_now_add=True, null=True)
class Meta:
verbose_name = "Konsum"
verbose_name_plural = "Konsum"
+113 -6
View File
@@ -88,7 +88,9 @@ label {
font-size: 0.95rem;
}
input[type="text"] {
input[type="text"],
input[type="number"],
select {
background-color: rgba(80, 40, 10, 0.4);
color: #EEEEEE;
border: 2px solid #885522;
@@ -96,9 +98,24 @@ input[type="text"] {
padding: 14px 12px;
font-size: 1.1rem;
width: 100%;
min-height: 52px;
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
font-family: inherit;
}
input[type="text"]:focus {
select {
background-image: url("data:image/svg+xml;utf8,<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;
border-color: #EE9933;
}
@@ -186,14 +203,58 @@ section {
.drink-btn {
width: 100%;
aspect-ratio: 4 / 3;
aspect-ratio: 3 / 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 12px 8px;
gap: 2px;
padding: 8px;
line-height: 1.2;
color: #161616;
text-shadow: none;
}
.drink-btn-beer {
background: linear-gradient(180deg, #f0c878 0%, #b8731a 100%);
}
.drink-btn-beer:hover, .drink-btn-beer:focus {
background: linear-gradient(180deg, #f7d699 0%, #cc8520 100%);
}
.drink-btn-radler {
background: linear-gradient(180deg, #f7e36b 0%, #d8a02a 100%);
}
.drink-btn-radler:hover, .drink-btn-radler:focus {
background: linear-gradient(180deg, #fff08c 0%, #e8b034 100%);
}
.drink-btn-alc_free_beer {
background: linear-gradient(180deg, #f0c878 0%, #5e8bbf 100%);
}
.drink-btn-alc_free_beer:hover, .drink-btn-alc_free_beer:focus {
background: linear-gradient(180deg, #f7d699 0%, #7aa3d1 100%);
}
.drink-btn-alc_free_radler {
background: linear-gradient(180deg, #f7e36b 0%, #5e8bbf 100%);
}
.drink-btn-alc_free_radler:hover, .drink-btn-alc_free_radler:focus {
background: linear-gradient(180deg, #fff08c 0%, #7aa3d1 100%);
}
.drink-btn-soft {
background: linear-gradient(180deg, #f0a35e 0%, #a85a22 100%);
}
.drink-btn-soft:hover, .drink-btn-soft:focus {
background: linear-gradient(180deg, #f7b878 0%, #c06a2c 100%);
}
.drink-btn-water {
background: linear-gradient(180deg, #d5e8f4 0%, #95c2dc 100%);
}
.drink-btn-water:hover, .drink-btn-water:focus {
background: linear-gradient(180deg, #e4f0f8 0%, #aad0e6 100%);
}
.drink-plus {
@@ -211,7 +272,7 @@ section {
.drink-price {
font-size: 0.85rem;
font-weight: normal;
opacity: 0.8;
opacity: 0.75;
}
.history {
@@ -377,3 +438,49 @@ section {
background-color: rgba(80, 40, 10, 0.4);
color: #FFCC77;
}
.progress-wrap {
position: relative;
width: 100%;
height: 28px;
background-color: rgba(80, 40, 10, 0.4);
border: 2px solid #885522;
border-radius: 6px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: #EE9933;
box-shadow: 0 0 12px #CC6611;
transition: width 200ms ease;
}
.progress-label {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: #161616;
font-weight: bold;
font-size: 0.95rem;
mix-blend-mode: screen;
}
.dash-row {
grid-template-columns: 1fr auto auto;
}
.link-btn-paypal {
background-color: #003087;
color: #FFFFFF;
}
.link-btn-paypal:hover, .link-btn-paypal:focus {
background-color: #0070BA;
color: #FFFFFF;
}
+207 -23
View File
@@ -2,10 +2,11 @@ from datetime import datetime
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth import get_user_model
from django.db.models import F, Sum
from django.db.models import Case, F, IntegerField, Sum, Value, When
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
@@ -13,14 +14,16 @@ from django.utils import timezone
from django.utils.text import slugify
from django.views.decorators.http import require_http_methods
from gaehsnitz.models import Consumption, Drink
from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
User = get_user_model()
BERLIN = ZoneInfo("Europe/Berlin")
BOOKING_START = datetime(2026, 6, 11, 0, 0, 0, tzinfo=BERLIN)
BOOKING_END = datetime(2026, 6, 14, 23, 59, 59, tzinfo=BERLIN)
READONLY_END = datetime(2026, 6, 21, 23, 59, 59, tzinfo=BERLIN)
# TEST WINDOW (2026-05-15 2026-05-31): enabled early for pre-festival testing.
# Original festival phase: BOOKING_START = 2026-06-11, BOOKING_END = 2026-06-14.
# Switch back to original dates before the festival.
BOOKING_START = datetime(2026, 5, 15, 0, 0, 0, tzinfo=BERLIN)
BOOKING_END = datetime(2026, 5, 31, 23, 59, 59, tzinfo=BERLIN)
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
@@ -37,17 +40,15 @@ def _phase():
return "before"
if now <= BOOKING_END:
return "booking"
if now <= READONLY_END:
return "readonly"
return "closed"
def _require_open(request):
"""Raise 404 once tool is fully closed. Return current phase otherwise."""
"""Redirect to closed page when tool is not in booking phase."""
phase = _phase()
if phase == "closed":
raise Http404
return phase
if phase in ("before", "closed"):
return HttpResponseRedirect(reverse("suff:closed")), None
return None, phase
def _current_festival_day():
@@ -60,23 +61,32 @@ def _normalize_name(raw):
def _tab_context(user):
consumption = user.consumption_list.select_related("drink").order_by("-created_at", "-id")
paid = (
user.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(total=Sum("cost"))[
"total"
]
year = current_year()
consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id")
total = (
user.paid_drinks.filter(drink__year=year)
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
.aggregate(total=Sum("cost"))["total"]
or 0
)
payments = user.user_payments.filter(created_at__year=year).order_by("-created_at")
paid = payments.aggregate(sum=Sum("amount"))["sum"] or 0
return {
"tab_user": user,
"consumption_list": consumption,
"total": paid,
"total": total,
"paid": paid,
"open_balance": total - paid,
"user_payments": payments,
"payment_methods": UserPayment.Method.choices,
}
@require_http_methods(["GET", "POST"])
def name_view(request):
phase = _require_open(request)
redirect, phase = _require_open(request)
if redirect:
return redirect
if request.user.is_authenticated:
return HttpResponseRedirect(reverse("suff:me"))
@@ -113,7 +123,9 @@ def name_view(request):
@require_http_methods(["GET", "POST"])
def pin_view(request):
phase = _require_open(request)
redirect, phase = _require_open(request)
if redirect:
return redirect
username = request.session.get("pending_username")
mode = request.session.get("pending_mode")
if not username or mode not in ("create", "login"):
@@ -164,8 +176,27 @@ def pin_view(request):
@login_required
@require_http_methods(["GET"])
def me_view(request):
phase = _require_open(request)
drinks = Drink.objects.order_by("name") if phase == "booking" else Drink.objects.none()
redirect, phase = _require_open(request)
if redirect:
return redirect
drinks = (
Drink.objects.filter(year=current_year())
.annotate(
category_order=Case(
When(category="beer", then=Value(0)),
When(category="alc_free_beer", then=Value(1)),
When(category="radler", then=Value(2)),
When(category="alc_free_radler", then=Value(3)),
When(category="soft", then=Value(4)),
When(category="water", then=Value(5)),
default=Value(99),
output_field=IntegerField(),
)
)
.order_by("category_order", "name")
if phase == "booking"
else Drink.objects.none()
)
booked_drink = None
booked_id = request.GET.get("booked")
if booked_id:
@@ -180,6 +211,7 @@ def me_view(request):
"drinks": drinks,
"current_day": _current_festival_day(),
"booked_drink": booked_drink,
"paid_toast": request.GET.get("paid") == "1",
}
)
return render(request, "suff/me.html", context)
@@ -188,7 +220,9 @@ def me_view(request):
@login_required
@require_http_methods(["POST"])
def book_view(request):
phase = _require_open(request)
redirect, phase = _require_open(request)
if redirect:
return redirect
if phase != "booking":
raise Http404
@@ -208,8 +242,158 @@ def book_view(request):
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
@login_required
@require_http_methods(["GET", "POST"])
def pay_view(request):
redirect, phase = _require_open(request)
if redirect:
return redirect
error = None
if request.method == "POST":
raw_amount = (request.POST.get("amount") or "").replace(",", ".").strip()
method = request.POST.get("method") or ""
note = (request.POST.get("note") or "").strip()[:64]
valid_methods = {m for m, _ in UserPayment.Method.choices}
try:
amount = float(raw_amount)
except ValueError:
amount = 0.0
if method not in valid_methods:
error = "Bitte eine Zahlungsmethode wählen."
elif amount <= 0:
error = "Bitte einen Betrag größer 0 eingeben."
else:
UserPayment.objects.create(
user=request.user,
amount=amount,
method=method,
note=note,
)
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
context = _tab_context(request.user)
context.update({"phase": phase, "error": error})
return render(request, "suff/pay.html", context)
DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
@staff_member_required
@require_http_methods(["GET"])
def dashboard_view(request):
year = current_year()
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
total_payments = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
total_balance = total_donations - total_payments
total_pct = int(round((total_donations / total_payments) * 100)) if total_payments else 0
total_pct_capped = min(total_pct, 100)
drinks = list(Drink.objects.filter(year=year).order_by("name"))
drink_rows = [
{
"name": d.name,
"sold": d.bottles_sold,
"total": d.bottles_total,
"balance": d.balance,
}
for d in drinks
]
sales_revenue = sum((d.sale_price_total for d in drinks), 0)
purchase_cost = sum((d.purchase_price_total for d in drinks), 0)
drinks_profit = sum((d.balance for d in drinks), 0)
refinance_pct = int(round((sales_revenue / purchase_cost) * 100)) if purchase_cost else 0
refinance_pct_capped = min(refinance_pct, 100)
user_rows = []
for user in User.objects.all():
consumed = user.consumed_drinks_price
if not consumed:
continue
paid = user.paid_amount
user_rows.append(
{
"username": user.username,
"consumed": consumed,
"paid": paid,
"open": consumed - paid,
}
)
user_rows.sort(key=lambda r: r["open"], reverse=True)
top_spender = None
if user_rows:
top = max(user_rows, key=lambda r: r["consumed"])
top_spender = {"username": top["username"], "total": top["consumed"]}
drink_amounts = (
Consumption.objects.filter(drink__year=year, for_free=False)
.values("drink__name")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
top_drink = None
if drink_amounts:
top_drink = {"name": drink_amounts[0]["drink__name"], "amount": drink_amounts[0]["amount"]}
day_amounts = (
Consumption.objects.filter(drink__year=year, for_free=False)
.values("day")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
busiest_day = None
if day_amounts:
d = day_amounts[0]
busiest_day = {"label": DAY_LABELS.get(d["day"], "?"), "amount": d["amount"]}
top_per_day = []
for day_num, label in DAY_LABELS.items():
rows = (
Consumption.objects.filter(drink__year=year, day=day_num, for_free=False)
.values("user__username")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
if rows:
r = rows[0]
top_per_day.append({"label": label, "username": r["user__username"], "amount": r["amount"]})
context = {
"year": year,
"total_donations": total_donations,
"total_payments": total_payments,
"total_balance": total_balance,
"total_pct": total_pct,
"total_pct_capped": total_pct_capped,
"drink_rows": drink_rows,
"sales_revenue": sales_revenue,
"purchase_cost": purchase_cost,
"drinks_profit": drinks_profit,
"refinance_pct": refinance_pct,
"refinance_pct_capped": refinance_pct_capped,
"user_rows": user_rows,
"top_spender": top_spender,
"top_drink": top_drink,
"busiest_day": busiest_day,
"top_per_day": top_per_day,
}
return render(request, "suff/dashboard.html", context)
@require_http_methods(["POST"])
def logout_view(request):
_require_open(request)
logout(request)
return HttpResponseRedirect(reverse("suff:name"))
@require_http_methods(["GET"])
def closed_view(request):
phase = _phase()
if phase == "booking":
return HttpResponseRedirect(reverse("suff:name"))
return render(request, "suff/closed.html", {"phase": phase, "booking_start": BOOKING_START})
+6
View File
@@ -2,9 +2,12 @@ from django.urls import path
from gaehsnitz.suff import (
book_view,
closed_view,
dashboard_view,
logout_view,
me_view,
name_view,
pay_view,
pin_view,
)
@@ -12,8 +15,11 @@ app_name = "suff"
urlpatterns = [
path("", name_view, name="name"),
path("closed/", closed_view, name="closed"),
path("pin/", pin_view, name="pin"),
path("me/", me_view, name="me"),
path("book/", book_view, name="book"),
path("pay/", pay_view, name="pay"),
path("dashboard/", dashboard_view, name="dashboard"),
path("logout/", logout_view, name="logout"),
]
+10
View File
@@ -0,0 +1,10 @@
{% 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 %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "suff/base.html" %}
{% load money %}
{% block content %}
<h2>Dashboard {{ year }}</h2>
<section>
<h3>Refinanzierung gesamt</h3>
<div class="progress-wrap">
<div class="progress-bar" style="width: {{ total_pct_capped }}%;"></div>
<span class="progress-label">{{ total_pct }}%</span>
</div>
<p class="muted-left">
Spenden {{ total_donations|euro }} / Ausgaben {{ total_payments|euro }}
</p>
<p class="muted-left">
Bilanz: <b>{{ total_balance|euro }}</b>
</p>
</section>
<section>
<h3>Refinanzierung Getränke</h3>
<div class="progress-wrap">
<div class="progress-bar" style="width: {{ refinance_pct_capped }}%;"></div>
<span class="progress-label">{{ refinance_pct }}%</span>
</div>
<p class="muted-left">
Verkaufserlös {{ sales_revenue|euro }} / Einkaufspreis {{ purchase_cost|euro }}
</p>
<p class="muted-left">
Aktueller Gewinn: <b>{{ drinks_profit|euro }}</b>
</p>
</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</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 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 %}
{% 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 %}
{% for f in top_per_day %}
<li>
<span class="hist-when">{{ f.label }}</span>
<span class="hist-what">{{ f.username }}</span>
<span class="hist-price">{{ f.amount }} Flaschen</span>
</li>
{% endfor %}
</ul>
</section>
<div class="logout-form">
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
+23 -5
View File
@@ -3,21 +3,33 @@
{% block content %}
<h2>Hallo {{ tab_user.username }}</h2>
{% if phase == "readonly" %}
<p><i>Festival vorbei. Buchungen geschlossen, nur noch Anzeige.</i></p>
{% endif %}
{% if booked_drink %}
<div class="toast" role="status">
Gebucht: +1 {{ booked_drink.name }}
</div>
{% endif %}
{% if paid_toast %}
<div class="toast" role="status">
Zahlung gespeichert. Danke!
</div>
{% endif %}
<section class="total-box">
<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 %}
</section>
{% if open_balance > 0 %}
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Bezahlen</a></p>
{% endif %}
{% if phase == "booking" %}
<section>
<h3>Neues Getränk buchen</h3>
@@ -26,7 +38,7 @@
<form method="post" action="{% url 'suff:book' %}">
{% csrf_token %}
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
<button type="submit" class="drink-btn">
<button type="submit" 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>
@@ -66,6 +78,12 @@
{% endif %}
</section>
{% if request.user.is_staff %}
<div class="link-row">
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Dashboard</a>
</div>
{% endif %}
<form method="post" action="{% url 'suff:logout' %}" class="logout-form">
{% csrf_token %}
<button type="submit" class="btn-secondary">Logout</button>
+74
View File
@@ -0,0 +1,74 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Bezahlen</h2>
<p>
Bitte zahle deinen offenen Betrag mit deiner bevorzugten Methode
(z.&nbsp;B. Bar an der Kasse oder PayPal an Flo) und trage
den bezahlten Betrag anschließend hier ein.
</p>
<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>
<section class="total-box">
<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</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
</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="number" name="amount" step="0.01" min="0.01"
value="{{ open_balance|floatformat:2 }}" 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>
</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 %}
+2 -1
View File
@@ -94,7 +94,8 @@ TEMPLATES = [
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
TIME_ZONE = "Europe/Berlin"
USE_I18N = False
LANGUAGE_CODE = "de"
USE_I18N = True
USE_L10N = False
USE_TZ = True
+1 -1
View File
@@ -10,7 +10,7 @@ dependencies = [
[dependency-groups]
dev = [
"ruff==0.15.13",
"ruff==0.15.14",
]
[tool.ruff]