Compare commits

...

28 Commits

Author SHA1 Message Date
flo b5142ce3c7 fix(dashboard): subtract cash prefill from income to fix Bilanz
500€ Kassen-Vorschuss was counted as income, inflating Einnahmen by 500€
and showing Bilanz -166€ instead of the correct -666€.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 16:26:53 +02:00
flo ae9d749356 feat(finance): add payment method field and post-festival accounting tools
- Add Payment.method field (cash/card/paypal/bank) with migration
- Show method in PaymentAdmin list view
- Add merge_users management command (atomic, reassigns all FKs)
- Add update_crates_2026 command (actual purchased/returned crates)
- Add finance_summary_2026 command (Ausgaben, Einnahmen, Kasse, Bilanz)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 16:19:05 +02:00
flo 89ea65a0c8 feat(suff): disable new user registration post-festival
New usernames now show a "party is over" page instead of proceeding to
account creation. Existing users can still log in. Extended BOOKING_END
to 2026-06-17 23:59 to allow edits through Wednesday night.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:36:30 +02:00
flo 2d611dcac5 feat(drinks): add Sekt category with rainbow button styling
- Add Drink.Category.sekt for consignment alcoholic drinks
- Order sekt under "mit Alkohol" section, after Radler
- Rainbow gradient CSS for .drink-btn-sekt
- Rebalance all drink button brightness to match rainbow midpoint
- Seed Sekty Drink (3.50€, no deposit, no purchase price)
- Update Sternburg Export 10.99→9.49, Altenburger Helles 15.99→13.99

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:54:29 +02:00
flo 752f129df9 rename(commands): fill_data_* → seed_* for consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:27:33 +02:00
flo 5b7fa07b44 Merge pull request 'chore(deps): update dependency django to v6.0.6' (#28) from renovate/django-6.x into main
Reviewed-on: #28
2026-06-09 13:23:27 +00:00
flo 01b37fda79 Merge pull request 'chore(deps): update dependency ruff to v0.15.16' (#29) from renovate/ruff-0.x into main
Reviewed-on: #29
2026-06-09 13:23:00 +00:00
flo 29e4b231da chore(deps): update dependency ruff to v0.15.16 2026-06-04 17:00:57 +00:00
flo 9fb5373f98 chore(deps): update dependency django to v6.0.6 2026-06-03 17:00:48 +00:00
flo de140c3feb fix(suff): use deselectable checkboxes for booking mode on staff page
Same fix as self-booking page: radio buttons replaced with checkboxes
with mutual-exclusivity JS, so Gratis/Direkt-bar can be deselected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:00:57 +02:00
flo a0ed28c587 feat(admin): improve user admin list view and detail page
Add is_superuser to list_display and list_filter. Add is_active,
is_staff, is_superuser, last_login (readonly), and user_permissions
to the user detail fieldset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:40:59 +02:00
flo 3ef2037917 split drinks by with/without alcohol + disable direct-cash button for anonym 2026-05-31 17:53:45 +02:00
flo 23b24ed0b9 feat(suff): UX improvements and bug fixes
- Style "Spenden" link as a compact right-aligned button
- Replace radio buttons with checkboxes for Gratis/Direkt-bezahlt (toggleable)
- Remove "Sonstiges" from payment method dropdown
- Disable submit buttons on form submit to prevent double-clicks and give loading feedback (fixes drink_id=None bug caused by disabled button value not being submitted)
- Block weak PINs (sequential and repeated digits)
- Limit usernames to 2–20 characters
- Style PIN errors consistently with other error messages
- Add self-service "PIN ändern" page, shown above Logout in 2-column layout
- Highlight own username in orange badge (matching staff-target cyan style)
- Update booking window: 2026-05-30 10:00 – 2026-06-14 22:00

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 17:37:30 +02:00
flo e0e2b08eef update main page 2026-05-29 14:55:37 +02:00
flo 88456eb5eb Merge pull request 'chore(deps): update dependency ruff to v0.15.15' (#27) from renovate/ruff-0.x into main
Reviewed-on: #27
2026-05-29 11:32:03 +00:00
flo 6558cbe015 chore(deps): update dependency ruff to v0.15.15 2026-05-28 17:00:54 +00:00
flo 95d1481cb4 feat(admin): show is_staff in user list with filter 2026-05-28 17:09:58 +02:00
flo d612acd715 Add settled balance panel, cash booking mode, and Kasse dashboard line
- Balance panel turns green with "Bezahlt ✓" + breakdown when open_balance <= 0, on all four tab pages (me, pay, staff_user, staff_pay)
- booking_mode radio on me.html and staff_user.html: normal / for_free / cash_paid; cash_paid auto-creates matching UserPayment(method=cash)
- Dashboard finance section shows "Kasse (bar)" sum of all cash payments for cross-checking
- staff_pay prefill lower-capped at 0; "Zahlung eintragen" always visible on staff_user

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:02:14 +02:00
flo 51d079a467 Add free-drink toggle, payment deletion, donation flow, expanded dashboard
- Crew can mark bookings as free (Gratis checkbox) on staff_user page;
  free anonymous bookings skip the auto-payment.
- Users (and crew) can delete their UserPayments with a confirmation page.
- Pay page redesigned around "Eintrittsspende": quick-pick amount buttons
  (drinks rounded to 5 € + 10/15/20/25/30), "Nur Drinks" escape, intro
  text, type=text input to dodge browser locale formatting.
- Me page: always-visible donate button, intro text linking drinks +
  Eintrittsspende.
- Day cutoff at 06:00 Berlin: festival day rolls at 06:00, applied to
  current_festival_day plus Frühaufsteher/Nachtschwärmer/Goldene Stunde.
- Dashboard: single Finanzen section (income vs costs) with breakdown,
  fun facts grouped (Trinker / Getränke / Zeit / Geld / Gratis).
- Offene Beträge filtered to open > 0.
- Staff list shows "(das bist du)" disabled row for own user.
- Renamed seed_drinks_2026 -> seed_2026, added Baumarkt 194 € entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:50:25 +02:00
flo 50fc32c577 Add booking deletion, crew views, anonymous walk-in, and account claim
- Users can delete their own bookings (confirmation page)
- Crew page tree (/suff/staff/): book/pay/delete for any user, register
  new users, set/reset PINs
- Anonymous walk-in user "anonym": bookings auto-create matching cash
  payment so balance stays at 0
- Self-signup: unknown name creates account (PIN required); known name
  without PIN and no activity allows claim (PIN required); known name
  without PIN but with activity blocks and points to bar crew
- Crew-only PIN set/reset; no random PINs, PINs never displayed
- Cyan .staff-target highlight on all crew pages
- Updated suff.md with current feature state and open ideas

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:05:20 +02:00
flo 980decb3fd Merge pull request 'Update dependency ruff to v0.15.14' (#26) from renovate/ruff-0.x into main
Reviewed-on: #26
2026-05-26 14:06:07 +00:00
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
46 changed files with 3225 additions and 255 deletions
+185 -17
View File
@@ -3,13 +3,18 @@ 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 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 from gaehsnitz.templatetags.money import euro
User = get_user_model() User = get_user_model()
@@ -20,6 +25,12 @@ 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)",
@@ -36,9 +47,9 @@ class SetPinForm(forms.Form):
@admin.register(User) @admin.register(User)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
list_display = ("username", "consumed_drinks_price") list_display = ("username", "is_staff", "is_superuser", "consumed_drinks_price", "paid_amount", "open_balance")
ordering = ("username",) ordering = ("username",)
list_filter = [] list_filter = ["is_staff", "is_superuser"]
fieldsets = ( fieldsets = (
( (
None, None,
@@ -47,17 +58,71 @@ class CustomUserAdmin(UserAdmin):
"username", "username",
"password", "password",
"pin_status", "pin_status",
"is_active",
"is_staff",
"is_superuser",
"last_login",
"user_permissions",
) )
}, },
), ),
("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",
"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"
@@ -104,10 +169,39 @@ 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):
@@ -116,8 +210,10 @@ class DonationAdmin(admin.ModelAdmin):
@admin.register(Payment) @admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin): class PaymentAdmin(admin.ModelAdmin):
list_display = ("date", "purpose", "amount") list_display = ("date", "purpose", "amount", "method")
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):
@@ -126,31 +222,46 @@ class PaymentAdmin(admin.ModelAdmin):
@admin.register(Drink) @admin.register(Drink)
class DrinkAdmin(admin.ModelAdmin): 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 = ( fieldsets = (
(None, {"fields": ("name",)}), (None, {"fields": ("name", "year")}),
( (
"crates", "Kästen",
{ {
"fields": ( "fields": (
"crates_ordered", "crates_ordered",
"crates_purchased", "crates_purchased",
"crates_full_returned",
"crates_returned", "crates_returned",
"crates_remaining",
) )
}, },
), ),
( (
"bottles", "Flaschen",
{ {
"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",
) )
}, },
), ),
( (
"amount", "Menge",
{ {
"fields": ( "fields": (
"bottle_size", "bottle_size",
@@ -160,17 +271,18 @@ class DrinkAdmin(admin.ModelAdmin):
}, },
), ),
( (
"purchase", "Einkauf",
{ {
"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",
) )
}, },
), ),
( (
"deposit", "Pfand",
{ {
"fields": ( "fields": (
"deposit_per_crate", "deposit_per_crate",
@@ -181,14 +293,12 @@ class DrinkAdmin(admin.ModelAdmin):
}, },
), ),
( (
"sales", "Verkauf",
{ {
"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",
) )
@@ -198,10 +308,15 @@ 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",
@@ -213,29 +328,82 @@ 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>")
@@ -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,86 @@
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")
@@ -0,0 +1,46 @@
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."
)
)
@@ -0,0 +1,67 @@
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}")
@@ -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)}")
@@ -0,0 +1,39 @@
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."))
@@ -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",
),
),
]
@@ -0,0 +1,27 @@
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),
]
@@ -0,0 +1,18 @@
# 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'),
),
]
@@ -0,0 +1,18 @@
# 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'),
),
]
+139 -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.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):
@@ -11,7 +16,11 @@ class PriceField(models.DecimalField):
class User(AbstractUser): 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): def set_pin(self, raw_pin):
self.pin = make_password(raw_pin) self.pin = make_password(raw_pin)
@@ -31,16 +40,31 @@ class User(AbstractUser):
@property @property
def consumed_drinks_price(self): def consumed_drinks_price(self):
query = self.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate( query = (
sum=Sum("cost") 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 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() date = models.DateField("Datum")
amount = PriceField() amount = PriceField("Betrag")
note = models.CharField(max_length=64, blank=True, default="") note = models.CharField("Notiz", max_length=64, blank=True, default="")
class Meta:
verbose_name = "Spende"
verbose_name_plural = "Spenden"
class Payment(models.Model): class Payment(models.Model):
@@ -49,26 +73,59 @@ class Payment(models.Model):
bands = 11, "Bands" bands = 11, "Bands"
supply_purchase = 12, "Getränke-/Essenseinkauf" supply_purchase = 12, "Getränke-/Essenseinkauf"
purpose = models.CharField(max_length=64) class Method(models.TextChoices):
date = models.DateField() cash = "cash", "Bar"
amount = PriceField() card = "card", "EC-Karte"
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):
name = models.CharField(max_length=32, unique=True) class Category(models.TextChoices):
crates_ordered = models.PositiveSmallIntegerField( beer = "beer", "Bier"
help_text="just informational to see how good we planned, not the actual consumed/paid drinks" 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_purchased = models.PositiveSmallIntegerField() crates_ordered = models.PositiveSmallIntegerField(
crates_returned = models.PositiveSmallIntegerField() "Kästen bestellt",
purchase_price_per_crate = PriceField() help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
deposit_per_crate = PriceField() )
bottles_per_crate = models.PositiveSmallIntegerField() crates_purchased = models.PositiveSmallIntegerField("Kästen gekauft")
bottle_size = models.FloatField() crates_returned = models.PositiveSmallIntegerField("Kästen leer zurück")
sale_price_per_bottle = PriceField() 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): def __str__(self):
return self.name return f"{self.name} {self.year}"
@property @property
def bottles_total(self): def bottles_total(self):
@@ -132,15 +189,71 @@ 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(
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( 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() amount = models.PositiveSmallIntegerField("Anzahl")
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")]) day = models.PositiveSmallIntegerField("Tag", choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
for_free = models.BooleanField(default=False) for_free = models.BooleanField("Gratis", default=False)
created_at = models.DateTimeField(auto_now_add=True, null=True) created_at = models.DateTimeField("Gebucht am", auto_now_add=True, null=True)
class Meta:
verbose_name = "Konsum"
verbose_name_plural = "Konsum"
Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

+18
View File
@@ -264,6 +264,24 @@ 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; }
+399 -8
View File
@@ -88,7 +88,9 @@ 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;
@@ -96,9 +98,24 @@ input[type="text"] {
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;
} }
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; outline: none;
border-color: #EE9933; border-color: #EE9933;
} }
@@ -123,6 +140,25 @@ 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);
} }
@@ -133,11 +169,100 @@ 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;
@@ -174,6 +299,76 @@ 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);
@@ -186,14 +381,65 @@ section {
.drink-btn { .drink-btn {
width: 100%; width: 100%;
aspect-ratio: 4 / 3; aspect-ratio: 3 / 2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; gap: 2px;
padding: 12px 8px; padding: 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 {
@@ -211,7 +457,7 @@ section {
.drink-price { .drink-price {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: normal; font-weight: normal;
opacity: 0.8; opacity: 0.75;
} }
.history { .history {
@@ -225,7 +471,8 @@ section {
.history li { .history li {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto 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);
@@ -233,6 +480,68 @@ 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);
} }
@@ -258,10 +567,42 @@ section {
color: #FFCC77; color: #FFCC77;
} }
.logout-form { .bottom-actions {
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;
@@ -377,3 +718,53 @@ 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;
}
+807 -36
View File
@@ -1,11 +1,14 @@
from datetime import datetime import math
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 F, Sum from django.db.models import Case, CharField, Count, F, IntegerField, Sum, Value, When
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
@@ -13,16 +16,24 @@ 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, Drink from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
User = get_user_model() User = get_user_model()
ANONYMOUS_USERNAME = "anonym"
BERLIN = ZoneInfo("Europe/Berlin") BERLIN = ZoneInfo("Europe/Berlin")
BOOKING_START = datetime(2026, 6, 11, 0, 0, 0, tzinfo=BERLIN) # Festival window: 2026-05-30 10:00 2026-06-14 22:00 Berlin time.
BOOKING_END = datetime(2026, 6, 14, 23, 59, 59, tzinfo=BERLIN) BOOKING_START = datetime(2026, 5, 30, 10, 0, 0, tzinfo=BERLIN)
READONLY_END = datetime(2026, 6, 21, 23, 59, 59, tzinfo=BERLIN) BOOKING_END = datetime(2026, 6, 17, 23, 59, 0, 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():
@@ -37,46 +48,120 @@ 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):
"""Raise 404 once tool is fully closed. Return current phase otherwise.""" """Redirect to closed page when tool is not in booking phase."""
phase = _phase() phase = _phase()
if phase == "closed": if phase in ("before", "closed"):
raise Http404 return HttpResponseRedirect(reverse("suff:closed")), None
return phase return None, phase
def _current_festival_day(): def _current_festival_day():
weekday = _now().weekday() # Day rolls at 06:00 Berlin: bookings before 06:00 count as previous day.
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 "")[:150] return slugify(raw or "")[:20]
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):
consumption = user.consumption_list.select_related("drink").order_by("-created_at", "-id") year = current_year()
paid = ( consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id")
user.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(total=Sum("cost"))[ total = (
"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 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": paid, "total": total,
"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):
phase = _require_open(request) redirect, 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"))
@@ -84,8 +169,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: if not username or (error := _username_error(username)):
error = "Bitte einen Namen eingeben." error = error or "Bitte einen Namen eingeben."
else: else:
try: try:
existing = User.objects.get(username=username) existing = User.objects.get(username=username)
@@ -93,16 +178,22 @@ def name_view(request):
existing = None existing = None
if existing is None: if existing is None:
request.session["pending_username"] = username return render(
request.session["pending_mode"] = "create" request,
return HttpResponseRedirect(reverse("suff:pin")) "suff/party_over.html",
{"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"
@@ -113,17 +204,19 @@ def name_view(request):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def pin_view(request): def pin_view(request):
phase = _require_open(request) redirect, 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"): if not username or mode not in ("create", "login", "claim"):
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 not (pin.isdigit() and len(pin) == 3): if error := _pin_error(pin):
error = "PIN muss aus genau 3 Ziffern bestehen." pass
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."
@@ -139,6 +232,23 @@ 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:
@@ -161,11 +271,33 @@ 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):
phase = _require_open(request) redirect, phase = _require_open(request)
drinks = Drink.objects.order_by("name") if phase == "booking" else Drink.objects.none() if redirect:
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:
@@ -180,6 +312,7 @@ 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)
@@ -188,11 +321,19 @@ def me_view(request):
@login_required @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def book_view(request): def book_view(request):
phase = _require_open(request) redirect, 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):
@@ -203,13 +344,643 @@ def book_view(request):
drink=drink, drink=drink,
amount=1, amount=1,
day=_current_festival_day(), day=_current_festival_day(),
for_free=False, for_free=for_free,
) )
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}") if cash_paid:
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})
+36
View File
@@ -2,18 +2,54 @@ 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"),
] ]
+19 -16
View File
@@ -1,14 +1,14 @@
{% extends "gaehsnitz/base.html" %} {% load money %} {% block content %} {% extends "gaehsnitz/base.html" %}
{% load static %} {% load money %}
{% block content %}
<h2>Gähsnitz Open Air</h2> <a href="{% static 'gaehsnitz/poster-2026.jpg' %}" target="_blank" class="main-poster-link">
<p>Unser eigenes kleines Festival ... oder auch nur eine große Gartenparty.</p> <img src="{% static 'gaehsnitz/poster-2026.jpg' %}" alt="Plakat 2026" class="main-poster">
<p>--- Plakat folgt ---</p> </a>
<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,7 +44,10 @@
<p>Donnerstag: Aufbau, Grundeinstellung, Soundcheck, evtl. Jam-Session &#x1F60E;</p> <p>Donnerstag: Aufbau, Grundeinstellung, Soundcheck, evtl. Jam-Session &#x1F60E;</p>
<p>Freitag:</p> <p>Freitag:</p>
<ul> <ul>
<li>18:45 Umbau + Line-Check Melo-Komplott</li> <li>16:30 Umbau + Line-Check Six Good Years</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>
@@ -61,14 +64,14 @@
</ul> </ul>
<h3>Backline für alle</h3> <h3>Backline für alle</h3>
<p>Drums von Josi bitte mit &#x2764; behandeln</p> <p>Drums von <span class="marked">Josi</span> bitte mit &#x2764; 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: <span class="marked">Ohli/Steve fragen / reicht eine?</span></li> <li>Gitarrenbox: 2x12", 8 Ohm, Klinke <span class="marked">(Steve)</span></li>
</ul> </ul>
<h3>Was mitgenutzt werden kann ...</h3> <h3>Was mitgenutzt werden kann ...</h3>
@@ -93,8 +96,8 @@
<h3>Nur zum Jammen / was wir ungern verleihen ...</h3> <h3>Nur zum Jammen / was wir ungern verleihen ...</h3>
<ul> <ul>
<li>Snare (Josi)</li> <li>Snare <span class="marked">(Tobi)</span></li>
<li>Becken: Crash, Ride, HiHat (Tobi)</li> <li>Becken: Crash, Ride, HiHat <span class="marked">(Tobi)</span></li>
</ul> </ul>
<h3>Mikrofonierung</h3> <h3>Mikrofonierung</h3>
@@ -104,14 +107,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 | <span class="marked">noch ein paar auftreiben</span></li> <li>Gesang: 1x Shure Beta 58 | 2x Shure SM58 <span class="marked">(geplant)</span></li>
<li>DI: <span class="marked">im Proberaum schauen / ausleihen</span></li> <li>DI: 1x Mono (Pedalboard) | 1x Mono <span class="marked">(PR)</span> | 2x Mono Palmer <span class="marked">(geplant)</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 class="marked">2x Alto Proberaum-PA reservieren</li> <li>2x Alto PA <span class="marked">(PR)</span></li>
<li class="marked">1x JBL alte Proberaum-PA ausprobieren</li> <li class="marked">1x JBL alte Proberaum-PA ausprobieren</li>
</ul> </ul>
@@ -124,7 +127,7 @@
<h3>Beleuchtung</h3> <h3>Beleuchtung</h3>
<ul> <ul>
<li class="marked">Carsten / Jakob fragen?</li> <li class="marked">alles von Franz</li>
</ul> </ul>
<h3>Kabel</h3> <h3>Kabel</h3>
+25
View File
@@ -14,5 +14,30 @@
<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>
+19
View File
@@ -0,0 +1,19 @@
{% 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 %}
+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 %}
@@ -0,0 +1,19 @@
{% 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 %}
@@ -0,0 +1,21 @@
{% 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 %}
+257
View File
@@ -0,0 +1,257 @@
{% 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 %}
+78 -13
View File
@@ -1,11 +1,13 @@
{% extends "suff/base.html" %} {% extends "suff/base.html" %}
{% block content %} {% block content %}
<h2>Hallo {{ tab_user.username }}</h2> <h2>Hallo <span class="self-username">{{ tab_user.username }}</span></h2>
{% if phase == "readonly" %} <p class="intro">
<p><i>Festival vorbei. Buchungen geschlossen, nur noch Anzeige.</i></p> Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
{% endif %} alles zusammen mit deiner <strong>Eintrittsspende</strong> bezahlen
bar oder per PayPal.
</p>
{% if booked_drink %} {% if booked_drink %}
<div class="toast" role="status"> <div class="toast" role="status">
@@ -13,27 +15,78 @@
</div> </div>
{% endif %} {% endif %}
<section class="total-box"> {% if paid_toast %}
<div class="toast" role="status">
Zahlung gespeichert. Danke!
</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-label">Deine Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</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>
<div class="drink-grid">
{% for drink in drinks %}
<form method="post" action="{% url 'suff:book' %}"> <form method="post" action="{% url 'suff:book' %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="drink_id" value="{{ drink.id }}" /> <label class="for-free-toggle">
<button type="submit" class="drink-btn"> <input type="checkbox" name="booking_mode" value="for_free" />
<span>Gratis (z.B. Artists am Spieltag)</span>
</label>
<label class="for-free-toggle">
<input type="checkbox" name="booking_mode" value="cash_paid" />
<span>Direkt bar bezahlt</span>
</label>
<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-plus">+1</span>
<span class="drink-name">{{ drink.name }}</span> <span class="drink-name">{{ drink.name }}</span>
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span> <span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
</button> </button>
</form>
{% endfor %} {% endfor %}
</div> </div>
{% endfor %}
</form>
</section> </section>
{% endif %} {% endif %}
@@ -52,6 +105,9 @@
<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>
@@ -61,13 +117,22 @@
<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>
<form method="post" action="{% url 'suff:logout' %}" class="logout-form"> {% if request.user.is_staff %}
<div class="link-row">
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Crew: Buchen für andere</a>
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Crew: Dashboard</a>
</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 %} {% csrf_token %}
<button type="submit" class="btn-secondary">Logout</button> <button type="submit" class="btn-secondary">Logout</button>
</form> </form>
</div>
{% endblock %} {% endblock %}
+5 -9
View File
@@ -1,20 +1,16 @@
{% extends "suff/base.html" %} {% extends "suff/base.html" %}
{% block content %} {% block content %}
<h2>Keine PIN gesetzt</h2> <h2>PIN benötigt</h2>
<p> <p>
Für den Namen <b>{{ username }}</b> ist noch keine PIN hinterlegt. Für den Namen <b>{{ username }}</b> ist keine PIN gesetzt, aber
der Account hat schon Buchungen oder Zahlungen.
</p> </p>
<p> <p>
Das ist ein Staff-Account. Aus Sicherheitsgründen kann die PIN für solche Aus Sicherheit kannst du diesen Account nicht selbst übernehmen.
Accounts nicht selbst gesetzt werden sonst könnte sich jeder mit dem Bitte jemanden an der Bar, dir eine neue PIN zu setzen.
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 %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Party vorbei!</h2>
<p>
Der Name <b>{{ username }}</b> existiert noch nicht &mdash;
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 %}
+118
View File
@@ -0,0 +1,118 @@
{% 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&nbsp;€ und legen 1030&nbsp;
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 -2
View File
@@ -5,12 +5,15 @@
<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><b>{{ error }}</b></p>{% endif %} {% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:pin' %}"> <form method="post" action="{% url 'suff:pin' %}">
{% csrf_token %} {% csrf_token %}
@@ -20,7 +23,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{% else %}Login{% endif %} {% if mode == "create" %}Account anlegen{% elif mode == "claim" %}Account übernehmen{% else %}Login{% endif %}
</button> </button>
</form> </form>
@@ -0,0 +1,24 @@
{% 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 %}
@@ -0,0 +1,18 @@
{% 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 %}
+45
View File
@@ -0,0 +1,45 @@
{% 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 %}
+81
View File
@@ -0,0 +1,81 @@
{% 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 %}
@@ -0,0 +1,24 @@
{% 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 %}
@@ -0,0 +1,29 @@
{% 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 %}
+132
View File
@@ -0,0 +1,132 @@
{% 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 %}
+2 -1
View File
@@ -94,7 +94,8 @@ TEMPLATES = [
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application" WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
TIME_ZONE = "Europe/Berlin" TIME_ZONE = "Europe/Berlin"
USE_I18N = False LANGUAGE_CODE = "de"
USE_I18N = True
USE_L10N = False USE_L10N = False
USE_TZ = True USE_TZ = True
+2 -2
View File
@@ -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.5", "django==6.0.6",
"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.12", "ruff==0.15.16",
] ]
[tool.ruff] [tool.ruff]
+77 -36
View File
@@ -1,64 +1,105 @@
# Suff drink booking tool # Suff drink booking tool
Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS, no CSS yet. Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS.
## Auth ## Auth
- `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. - `User.pin` (hashed CharField, 3 digits), separate from `password`. Strong password stays for `/admin/`.
- `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. - `PinBackend` authenticates by `username` + `pin`. `ModelBackend` first in `AUTHENTICATION_BACKENDS` so admin still needs strong password.
- 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. - PIN reset is **crew-only**. No self-reset, no random PINs, PINs never displayed.
- 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)` (e.g. "Flo Hä!" → "flo-ha"). Slug shown back so user can memorize it. Username = `slugify(input)`. POST name:
- POST name → check existence:
- not found → set new PIN → create user → login - not found → `create` mode → mandatory 3-digit PIN → create user → login
- found, has PIN → enter PIN → login - found, has PIN → `login` mode → enter PIN
- found, no PIN`no_pin.html` (ask admin) - found, no PIN, **no activity** (no Consumption + no UserPayment) → `claim` mode → set PIN → login
- found, no PIN, **has activity**`no_pin.html` ("ask someone at the bar")
## Booking ## Booking
- `/suff/me/` shows: greeting (slug), running paid total, full consumption history with timestamps, drink buttons. - `/suff/me/` shows: greeting, total/paid/open balance, drink grid, day-grouped history.
- Each drink = `+1` POST form. Server creates `Consumption(amount=1, day=current_weekday, for_free=False, created_at=auto)`. - Single form wraps two radio buttons (`booking_mode`) + all drink buttons (`name="drink_id"`).
- No undo, no delete, no edit. No special bartender role. - `booking_mode` values: `normal` (default, no radio selected), `for_free`, `cash_paid`.
- History sorted newest-first, `created_at` shown as `Do 18:42` etc. - `+1` POST creates `Consumption(amount=1, day=current_weekday, for_free=...)`.
- `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` / `readonly` / `closed`. - Phases: `before` / `booking` / `closed`.
- Booking allowed Thu 2026-06-11 00:00 → Sun 2026-06-14 23:59. - Test window: 2026-05-15 → 2026-05-31. Original festival: 2026-06-11 → 2026-06-14.
- Read-only until Sun 2026-06-21 23:59. - `closed` shows static page; outside booking, all action endpoints redirect or 404.
- After: every `/suff/` URL returns 404. - `settings.PRODUCTION=False` forces `booking`.
- 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` — views + phase logic - `gaehsnitz/suff.py` all suff views + phase + crew helpers
- `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}.html` - `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/admin/gaehsnitz/user/set_pin.html`
- `gaehsnitz/static/suff/{style.css,favicon.svg}` - `gaehsnitz/static/suff/{style.css,favicon.svg}`
- `gaehsnitz/migrations/0003_consumption_created_at_user_pin.py` - `gaehsnitz/migrations/0009_anonymous_user.py` — seeds `anonym`
- Edits: `gaehsnitzproject/settings.py`, `gaehsnitzproject/urls.py`, `gaehsnitz/models.py`
## Frontend ## Frontend
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. 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.
- Landing/login: GOA subhead + big "Suff" wordmark, `name` and `pin` forms with stacked label/input, large tap targets ## Open ideas / next session
- `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 (🍺)
## Further ideas ### Pay-on-the-spot / quick-pay-cash
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.)
### 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
- Color-code drink buttons (per-drink accent border or bg — Bier amber, Wasser blue, etc.) for fast visual recognition in dim light
- Drink icons/emoji per type
- Style phase pages (`before` / `closed` if non-404)
- PWA manifest for add-to-homescreen - PWA manifest for add-to-homescreen
- Donation/free-drink flow if needed (currently admin-only via `for_free`) - Drink icons/emoji per type
- Style phase pages (`before` / `closed`)
- Per-user QR for fast crew lookup at the bar