Compare commits

..

1 Commits

Author SHA1 Message Date
flo e9261e5dc2 Update dependency gunicorn to v25 2026-02-02 17:00:59 +00:00
70 changed files with 806 additions and 5357 deletions
-10
View File
@@ -1,10 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
trim_trailing_whitespace = true
+4 -6
View File
@@ -1,10 +1,8 @@
FROM python:3.14-alpine
ENV PYTHONUNBUFFERED=1
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
COPY pyproject.toml .
ENV VIRTUAL_ENV=/opt/venv
RUN uv venv $VIRTUAL_ENV && uv pip install -r pyproject.toml
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /code/
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install --no-cache-dir --requirement requirements.txt
COPY . .
ENTRYPOINT ["./entrypoint.sh"]
+3 -2
View File
@@ -1,4 +1,5 @@
services:
db:
image: postgres:18-alpine
init: true
@@ -9,7 +10,7 @@ services:
expose:
- "5432"
volumes:
- ./volumes/db/postgresql:/var/lib/postgresql:rw
- ./volumes/db/data:/var/lib/postgresql/data:rw
web:
build:
@@ -26,4 +27,4 @@ services:
ports:
- "80:8000"
volumes:
- .:/app
- .:/code:rw
+35 -306
View File
@@ -1,20 +1,9 @@
from django import forms
from django.contrib import admin, messages
from django.contrib.admin.utils import unquote
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group
admin.site.unregister(Group)
from django.http import Http404, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.db.models import Sum
from gaehsnitz.models import Donation, Payment, Drink, Consumption, UserPayment, current_year
from gaehsnitz.models import Donation, Payment, Drink, Consumption
from gaehsnitz.templatetags.money import euro
User = get_user_model()
@@ -25,183 +14,33 @@ class ConsumptionInline(admin.TabularInline):
extra = 0
class UserPaymentInline(admin.TabularInline):
model = UserPayment
extra = 0
readonly_fields = ("created_at",)
class SetPinForm(forms.Form):
pin = forms.CharField(
label="Neue PIN (3 Ziffern)",
min_length=3,
max_length=3,
)
def clean_pin(self):
pin = self.cleaned_data["pin"]
if not pin.isdigit():
raise forms.ValidationError("PIN muss aus genau 3 Ziffern bestehen.")
return pin
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ("username", "is_staff", "is_superuser", "consumed_drinks_price", "paid_amount", "open_balance")
list_display = ("username", "consumed_drinks_price")
ordering = ("username",)
list_filter = ["is_staff", "is_superuser"]
list_filter = []
fieldsets = (
(
None,
{
"fields": (
(None, {"fields": (
"username",
"password",
"pin_status",
"is_active",
"is_staff",
"is_superuser",
"last_login",
"user_permissions",
)
},
),
(
"BILANZ",
{
"fields": (
)}),
("BALANCE", {"fields": (
"consumed_drinks_price",
"paid_amount",
"open_balance",
"drinks_breakdown",
"free_drinks_breakdown",
)
},
),
)}),
)
readonly_fields = (
"consumed_drinks_price",
"paid_amount",
"open_balance",
"pin_status",
"last_login",
"drinks_breakdown",
"free_drinks_breakdown",
)
inlines = (UserPaymentInline, ConsumptionInline)
inlines = (ConsumptionInline,)
@admin.display(description="Konsumiert")
def consumed_drinks_price(self, user: User):
return euro(user.consumed_drinks_price)
@admin.display(description="Bezahlt")
def paid_amount(self, user: User):
return euro(user.paid_amount)
@admin.display(description="Offener Betrag")
def open_balance(self, user: User):
return euro(user.open_balance)
def _breakdown(self, user: User, for_free: bool):
if user.pk is None:
return "-"
rows = (
user.consumption_list.filter(for_free=for_free, drink__year=current_year())
.values("drink__name")
.annotate(amount=Sum("amount"))
.order_by("drink__name")
)
if not rows:
return "-"
return ", ".join(f"{r['amount']}x {r['drink__name']}" for r in rows)
@admin.display(description="Bezahlt")
def drinks_breakdown(self, user: User):
return self._breakdown(user, for_free=False)
@admin.display(description="Gratis")
def free_drinks_breakdown(self, user: User):
return self._breakdown(user, for_free=True)
@admin.display(description="PIN")
def pin_status(self, user: User):
status = "gesetzt" if user.pin else "nicht gesetzt"
if user.pk is None:
return status
url = reverse("admin:gaehsnitz_user_set_pin", args=[user.pk])
return format_html('{} &nbsp; <a href="{}">PIN setzen</a>', status, url)
def get_urls(self):
urls = super().get_urls()
custom = [
path(
"<id>/pin/",
self.admin_site.admin_view(self.set_pin_view),
name="gaehsnitz_user_set_pin",
),
]
return custom + urls
def set_pin_view(self, request, id):
if not self.has_change_permission(request):
raise Http404
user = self.get_object(request, unquote(id))
if user is None:
raise Http404
if request.method == "POST":
form = SetPinForm(request.POST)
if form.is_valid():
user.set_pin(form.cleaned_data["pin"])
user.save(update_fields=["pin"])
messages.success(request, f"PIN für {user.username} gesetzt.")
return HttpResponseRedirect(reverse("admin:gaehsnitz_user_change", args=[user.pk]))
else:
form = SetPinForm()
context = {
**self.admin_site.each_context(request),
"title": f"PIN setzen für {user.username}",
"opts": self.model._meta,
"original": user,
"form": form,
}
return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context)
class YearFilter(admin.SimpleListFilter):
title = "Jahr"
parameter_name = "year"
field_name = "created_at"
def lookups(self, request, model_admin):
years = model_admin.model.objects.dates(self.field_name, "year", order="DESC")
return [(y.year, str(y.year)) for y in years]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(**{f"{self.field_name}__year": self.value()})
return queryset
@admin.register(UserPayment)
class UserPaymentAdmin(admin.ModelAdmin):
list_display = ("created_at", "user", "amount", "method", "note")
list_filter = ("method", YearFilter)
ordering = ("-created_at",)
search_fields = ("user__username", "note")
@admin.display(ordering="amount")
def amount(self, payment: UserPayment):
return euro(payment.amount)
@admin.register(Donation)
class DonationAdmin(admin.ModelAdmin):
list_display = ("date", "amount", "note")
list_filter = ("date",)
ordering = ("-date",)
search_fields = ("note",)
@admin.display(ordering="amount")
def amount(self, donation: Donation):
@@ -210,10 +49,8 @@ class DonationAdmin(admin.ModelAdmin):
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ("date", "purpose", "amount", "method")
list_filter = ("method", "date")
list_display = ("date", "purpose", "amount")
ordering = ("-date",)
search_fields = ("purpose",)
@admin.display(ordering="amount")
def amount(self, payment: Payment):
@@ -222,188 +59,80 @@ class PaymentAdmin(admin.ModelAdmin):
@admin.register(Drink)
class DrinkAdmin(admin.ModelAdmin):
list_display = (
"name",
"year",
"crates_purchased",
"bottles_sold",
"bottles_remaining",
"purchase_price_total",
"balance",
)
list_filter = ("year",)
list_display = ("name", "purchase_price_per_crate", "crates_purchased", "purchase_price_total")
fieldsets = (
(None, {"fields": ("name", "year")}),
(
"Kästen",
{
"fields": (
(None, {"fields": (
"name",
)}),
("crates", {"fields": (
"crates_ordered",
"crates_purchased",
"crates_full_returned",
"crates_returned",
"crates_remaining",
)
},
),
(
"Flaschen",
{
"fields": (
)}),
("bottles", {"fields": (
"bottles_per_crate",
"bottles_total",
"bottles_returned",
"bottles_sold",
"bottles_given_away",
"bottles_consumed",
"bottles_remaining",
)
},
),
(
"Menge",
{
"fields": (
)}),
("amount", {"fields": (
"bottle_size",
"amount_per_crate",
"amount_total",
)
},
),
(
"Einkauf",
{
"fields": (
)}),
("purchase", {"fields": (
"purchase_price_per_crate",
"purchase_price_per_bottle",
"purchase_price_total",
"remaining_purchase_value",
)
},
),
(
"Pfand",
{
"fields": (
)}),
("deposit", {"fields": (
"deposit_per_crate",
"deposit_total",
"deposit_refund",
"deposit_kept",
)
},
),
(
"Verkauf",
{
"fields": (
)}),
("sales", {"fields": (
"sale_price_per_bottle",
"sales_purchase_value",
"sale_price_total",
"giveaway_purchase_value",
"balance",
)
},
),
)
readonly_fields = (
"bottles_total",
"bottles_returned",
"bottles_consumed",
"bottles_remaining",
"crates_full_returned",
"crates_remaining",
"amount_per_crate",
"amount_total",
"purchase_price_per_bottle",
"purchase_price_total",
"remaining_purchase_value",
"deposit_total",
"deposit_refund",
"deposit_kept",
"bottles_sold",
"sales_purchase_value",
"sale_price_total",
"bottles_given_away",
"giveaway_purchase_value",
"balance",
)}),
)
readonly_fields = (
"bottles_total", "bottles_returned",
"amount_per_crate", "amount_total",
"purchase_price_per_bottle", "purchase_price_total",
"deposit_total", "deposit_refund", "deposit_kept",
"bottles_sold", "sales_purchase_value", "sale_price_total",
"bottles_given_away", "giveaway_purchase_value",
"balance",
)
@admin.display(description="Kästen voll zurück")
def crates_full_returned(self, drink: Drink):
return drink.crates_full_returned
@admin.display(description="Kästen übrig")
def crates_remaining(self, drink: Drink):
return drink.crates_remaining
@admin.display(description="Flaschen gesamt")
def bottles_total(self, drink: Drink):
return drink.bottles_total
@admin.display(description="Flaschen leer zurück")
def bottles_returned(self, drink: Drink):
return drink.bottles_returned
@admin.display(description="Flaschen verkauft")
def bottles_sold(self, drink: Drink):
return drink.bottles_sold
@admin.display(description="Flaschen verschenkt")
def bottles_given_away(self, drink: Drink):
return drink.bottles_given_away
@admin.display(description="Flaschen konsumiert")
def bottles_consumed(self, drink: Drink):
return drink.bottles_consumed
@admin.display(description="Flaschen übrig")
def bottles_remaining(self, drink: Drink):
return drink.bottles_remaining
@admin.display(description="Menge pro Kasten (l)")
def amount_per_crate(self, drink: Drink):
return drink.amount_per_crate
@admin.display(description="Menge gesamt (l)")
def amount_total(self, drink: Drink):
return drink.amount_total
@admin.display(description="Einkaufspreis pro Flasche")
def purchase_price_per_bottle(self, drink: Drink):
return euro(drink.purchase_price_per_bottle)
@admin.display(description="Einkaufspreis gesamt")
def purchase_price_total(self, drink: Drink):
return euro(drink.purchase_price_total)
@admin.display(description="Einkaufswert übrig")
def remaining_purchase_value(self, drink: Drink):
return euro(drink.remaining_purchase_value)
@admin.display(description="Pfand gesamt")
def deposit_total(self, drink: Drink):
return euro(drink.deposit_total)
@admin.display(description="Pfand zurück")
def deposit_refund(self, drink: Drink):
return euro(drink.deposit_refund)
@admin.display(description="Pfand einbehalten")
def deposit_kept(self, drink: Drink):
return euro(drink.deposit_kept)
@admin.display(description="Einkaufswert verkauft")
def sales_purchase_value(self, drink: Drink):
return euro(drink.sales_purchase_value)
@admin.display(description="Verkaufserlös")
def sale_price_total(self, drink: Drink):
return euro(drink.sale_price_total)
@admin.display(description="Einkaufswert verschenkt")
def giveaway_purchase_value(self, drink: Drink):
return euro(drink.giveaway_purchase_value)
@admin.display(description="Bilanz")
def balance(self, drink: Drink):
return mark_safe(f"<b>{euro(drink.balance)}</b>")
-25
View File
@@ -1,25 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class PinBackend(ModelBackend):
"""Authenticate festival users by username + 3-digit PIN stored in `pin`.
Strong passwords stay on the User model for /admin/ via the default
ModelBackend. Staff can also set a PIN to use the drink tool with the
same username.
"""
def authenticate(self, request, username=None, pin=None, **kwargs):
if username is None or pin is None:
return None
User = get_user_model()
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return None
if not user.check_pin(pin):
return None
if not self.user_can_authenticate(user):
return None
return user
@@ -0,0 +1,33 @@
from django.core.management import BaseCommand
from django.db.models import Sum
from gaehsnitz.models import Drink
from gaehsnitz.templatetags.money import euro
class Command(BaseCommand):
def handle(self, *args, **options):
for drink in Drink.objects.all():
print(f"--- {drink.name} ---")
print(f"Kästen (laut Abrechnung):")
ordered = drink.crates_ordered
print(f" bestellt: {ordered}")
purchased = drink.crates_purchased
print(f" gekauft: {purchased}")
full_ret = ordered - purchased
print(f" voll zurück: {full_ret}")
empty_ret = drink.crates_returned
print(f" leer zurück: {empty_ret}")
remaining = purchased - empty_ret
print(f" übrig: {remaining}")
print("Flaschen (laut Strichliste):")
bought = drink.bottles_total
print(f" gekauft: {bought}")
consumed = drink.consumption_list.aggregate(sum=Sum("amount"))["sum"] or 0
print(f" getrunken: {consumed}")
remaining = bought - consumed
print(f" übrig: {remaining}")
purchase_value = remaining * drink.purchase_price_per_bottle
print(f" Wert: {euro(purchase_value)}")
@@ -46,14 +46,16 @@ def payback(user, day, month, amount, note):
def consumption(day: int, for_free: bool, drink_dict: Dict[User, List[Tuple[int, Drink]]]):
for user, drink_list in drink_dict.items():
for amount, drink in drink_list:
Consumption.objects.create(user=user, drink=drink, amount=amount, day=day, for_free=for_free)
Consumption.objects.create(
user=user, drink=drink, amount=amount, day=day, for_free=for_free)
class Command(BaseCommand):
@transaction.atomic()
def handle(self, *args, **options):
if any(model.objects.exists() for model in [Donation, Payment, Drink, Consumption]):
raise CommandError("clear all donation, payment, drink and consumption objects before running this command")
raise CommandError(
"clear all donation, payment, drink and consumption objects before running this command")
# --------------- PEOPLE ---------------
@@ -222,10 +224,7 @@ class Command(BaseCommand):
# --------------- THURSDAY ---------------
consumption(
day=1,
for_free=False,
drink_dict={
consumption(day=1, for_free=False, drink_dict={
robert: [(2, mate), (2, radler), (1, wasser)],
thure: [(4, sterni), (1, krosti), (2, radler)],
tobi: [(7, sterni), (2, krosti)],
@@ -238,22 +237,14 @@ class Command(BaseCommand):
lutz: [(1, sterni), (1, wasser)],
lasse: [(2, sterni), (1, wasser)],
domi: [(1, sterni), (1, radler)],
},
)
consumption(
day=1,
for_free=True,
drink_dict={
})
consumption(day=1, for_free=True, drink_dict={
seth_family: [(1, krosti), (1, radler)],
},
)
})
# --------------- FRIDAY ---------------
consumption(
day=2,
for_free=False,
drink_dict={
consumption(day=2, for_free=False, drink_dict={
robert: [(2, mate), (1, radler)],
thure: [(8, sterni), (2, krosti), (1, wasser)],
tobi: [(2, mate), (5, sterni), (5, krosti), (3, radler), (1, cola)],
@@ -266,29 +257,21 @@ class Command(BaseCommand):
peter: [(7, sterni), (1, krosti), (2, wasser)],
fiddi_melli: [(6, sterni), (3, radler), (1, wasser)],
suse: [(1, krosti), (1, radler)],
lilly_simon: [(1, mate), (7, sterni), (3, wasser)],
},
)
lilly_simon: [(1, mate), (7, sterni), (3, wasser)]
})
consumption(
day=2,
for_free=True,
drink_dict={
consumption(day=2, for_free=True, drink_dict={
seth_family: [(1, mate), (6, sterni), (3, wasser)],
aimee: [(1, mate), (9, sterni), (3, wasser)],
lutz: [(1, mate), (9, sterni), (3, wasser)],
lasse: [(7, sterni), (1, wasser)],
domi: [(1, mate), (4, sterni), (1, wasser)],
hans_welle: [(3, mate), (7, sterni), (7, krosti), (1, cola), (2, wasser)],
},
)
})
# --------------- SATURDAY ---------------
consumption(
day=3,
for_free=False,
drink_dict={
consumption(day=3, for_free=False, drink_dict={
robert: [(4, mate), (1, cola), (4, wasser)],
tobi: [(9, sterni), (1, krosti), (2, radler), (1, cola)],
herald: [(2, mate), (2, radler), (3, wasser), (5, wein)],
@@ -307,21 +290,16 @@ class Command(BaseCommand):
kevin: [(1, mate), (1, radler)],
rebecca: [(1, radler)],
resi_simon: [(8, sterni), (1, radler), (1, wasser)],
},
)
})
consumption(
day=3,
for_free=True,
drink_dict={
consumption(day=3, for_free=True, drink_dict={
thure: [(1, mate), (4, sterni), (5, radler), (3, wasser)],
seth_family: [(1, mate), (2, sterni), (12, krosti), (1, wasser)],
andrew: [(3, sterni), (2, krosti), (3, radler), (1, wasser)],
robin: [(1, mate), (4, krosti), (2, radler)],
melokomplott: [(5, mate), (13, sterni), (1, krosti), (1, radler)],
residudes: [(2, mate), (1, sterni), (15, krosti), (7, radler), (1, wasser)],
},
)
})
# --------------- ADMIN STUFF ---------------
@@ -35,14 +35,16 @@ def payment(purpose, day, month, amount):
def consumption(day: int, for_free: bool, drink_dict: Dict[User, List[Tuple[int, Drink]]]):
for user, drink_list in drink_dict.items():
for amount, drink in drink_list:
Consumption.objects.create(user=user, drink=drink, amount=amount, day=day, for_free=for_free)
Consumption.objects.create(
user=user, drink=drink, amount=amount, day=day, for_free=for_free)
class Command(BaseCommand):
@transaction.atomic()
def handle(self, *args, **options):
if any(model.objects.exists() for model in [Donation, Payment, Drink, Consumption]):
raise CommandError("clear all donation, payment, drink and consumption objects before running this command")
raise CommandError(
"clear all donation, payment, drink and consumption objects before running this command")
# --------------- PEOPLE ---------------
@@ -230,10 +232,7 @@ class Command(BaseCommand):
# --------------- THURSDAY ---------------
consumption(
day=1,
for_free=False,
drink_dict={
consumption(day=1, for_free=False, drink_dict={
enni: [(2, wasser), (9, sterni)],
robert: [(2, wasser), (2, freiberger), (1, spezi), (2, mate)],
josi: [(5, sterni), (1, radler)],
@@ -241,15 +240,11 @@ class Command(BaseCommand):
annemarie: [(1, wasser), (4, sterni), (1, radler)],
sandra: [(1, wasser), (3, sterni), (1, spezi), (1, mate)],
flo: [(2, wasser), (5, sterni), (1, mate), (1, helles)],
},
)
})
# --------------- FRIDAY ---------------
consumption(
day=2,
for_free=False,
drink_dict={
consumption(day=2, for_free=False, drink_dict={
tobi: [(2, wasser), (9, sterni), (2, freiberger)],
annemarie: [(1, wasser), (5, sterni), (2, mate), (2, radler), (2, buddi), (3, sekt)],
sandra: [(1, freiberger), (1, buddi), (1, spezi), (2, mate), (1, radler), (2, sekt)],
@@ -272,28 +267,20 @@ class Command(BaseCommand):
dennis: [(1, sterni), (2, krosti)],
marvin: [(3, sterni), (1, krosti), (1, mate)],
simon: [(1, sterni)],
},
)
})
consumption(
day=2,
for_free=True,
drink_dict={
consumption(day=2, for_free=True, drink_dict={
flo: [(2, wasser), (3, sterni), (1, freiberger), (3, helles)],
casi: [(6, sterni)],
sepp: [(6, sterni)],
ohli: [(4, sterni)],
marius: [(1, radler)],
anonym: [(10, sterni), (10, krosti), (2, radler), (4, helles), (1, sekt)],
},
)
anonym: [(10, sterni), (10, krosti), (2, radler), (4, helles), (1, sekt)]
})
# --------------- SATURDAY ---------------
consumption(
day=3,
for_free=False,
drink_dict={
consumption(day=3, for_free=False, drink_dict={
herald: [(1, sterni), (2, freiberger), (2, radler), (1, buddi), (5, spezi), (2, wasser)],
thure: [(6, sterni), (1, krosti), (1, radler), (2, spezi)],
sandra: [(6, sterni), (1, freiberger), (2, radler), (1, mate), (1, cola), (1, sekt)],
@@ -318,18 +305,13 @@ class Command(BaseCommand):
lilly: [(5, sterni), (1, mate)],
andrea: [(1, buddi)],
lena: [(1, sterni)],
anonym: [(2, helles), (3, sterni), (5, krosti), (2, radler), (1, cola), (2, sekt)],
},
)
anonym: [(2, helles), (3, sterni), (5, krosti), (2, radler), (1, cola), (2, sekt)]
})
consumption(
day=3,
for_free=True,
drink_dict={
consumption(day=3, for_free=True, drink_dict={
rockbert: [(8, krosti), (1, radler), (1, mate)],
anonym: [(8, sterni), (2, krosti), (2, mate), (1, cola)],
},
)
anonym: [(8, sterni), (2, krosti), (2, mate), (1, cola)]
})
# --------------- ADMIN STUFF ---------------
@@ -1,86 +0,0 @@
from decimal import Decimal, ROUND_HALF_UP
from django.core.management.base import BaseCommand
from django.db.models import F, Sum
from gaehsnitz.models import Consumption, Payment, UserPayment
YEAR = 2026
CASH_PREFILL = Decimal("500.00")
def eur(amount):
return str(Decimal(amount).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
class Command(BaseCommand):
help = "Print full 2026 festival finance summary"
def handle(self, *args, **options):
w = self.stdout.write
sep = "-" * 40
# --- Ausgaben ---
w("\nAUSGABEN")
w(sep)
payments = Payment.objects.filter(date__year=YEAR).order_by("date")
total_out = Decimal("0")
for p in payments:
method_label = dict(Payment.Method.choices).get(p.method, p.method)
w(f" {p.date} {eur(p.amount):>6}{method_label:<15} {p.purpose}")
total_out += p.amount
w(sep)
w(f" {'TOTAL':<11} {eur(total_out):>6}")
# by method
w("")
for method, label in Payment.Method.choices:
subtotal = payments.filter(method=method).aggregate(s=Sum("amount"))["s"] or Decimal("0")
if subtotal:
w(f" {label:<15} {eur(subtotal):>6}")
# --- Einnahmen ---
w("\nEINNAHMEN")
w(sep)
up = UserPayment.objects.filter(created_at__year=YEAR)
cash_prefill = CASH_PREFILL
cash_payments = up.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
cash_from_sales = cash_payments - cash_prefill
non_cash = up.exclude(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
total_in = cash_from_sales + non_cash
drink_revenue = Consumption.objects.filter(for_free=False, drink__year=YEAR).annotate(
cost=F("amount") * F("drink__sale_price_per_bottle")
).aggregate(s=Sum("cost"))["s"] or Decimal("0")
entry_donations = total_in - drink_revenue
w(f" Cash: {eur(cash_from_sales):>6}")
w(f" Cashless: {eur(non_cash):>6}")
w(f" Einnahmen Gesamt: {eur(total_in):>6}")
w(f" - Getränke-Umsatz: {eur(drink_revenue):>6}")
w(f" - Eintrittsspenden: {eur(entry_donations):>6}")
# --- Kassensaldo ---
w("\nKASSE")
w(sep)
cash_out = payments.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
expected_cash = cash_prefill + cash_from_sales - cash_out
w(f" Kassen-Vorschuss: {eur(cash_prefill):>6}")
w(f" Cash-Einnahmen: {eur(cash_from_sales):>6}")
w(f" Cash-Ausgaben: {eur(cash_out):>6}")
w(f" -> in Kasse: {eur(expected_cash):>6}")
# --- Gesamtbilanz ---
w("\nGESAMTBILANZ")
w(sep)
net = total_in - total_out
out_of_pocket = total_out - total_in
w(f" Gesamtausgaben: {eur(total_out):>6}")
w(f" Gesamteinnahmen: {eur(total_in):>6}")
w(sep)
label = "Verlust" if out_of_pocket > 0 else "Gewinn"
w(f" {label}: {eur(abs(net)):>6}\n")
@@ -1,46 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from gaehsnitz.models import User
class Command(BaseCommand):
help = "Merge source user into target user, then delete source"
def add_arguments(self, parser):
parser.add_argument("source", help="Username of user to merge FROM (will be deleted)")
parser.add_argument("target", help="Username of user to merge INTO (will be kept)")
def handle(self, *args, **options):
source_name = options["source"]
target_name = options["target"]
if source_name == target_name:
raise CommandError("Source and target must be different users")
try:
source = User.objects.get(username=source_name)
except User.DoesNotExist:
raise CommandError(f"Source user '{source_name}' not found")
try:
target = User.objects.get(username=target_name)
except User.DoesNotExist:
raise CommandError(f"Target user '{target_name}' not found")
with transaction.atomic():
payments = source.user_payments.count()
consumptions = source.consumption_list.count()
source.user_payments.update(user=target)
source.consumption_list.update(user=target)
source.delete()
self.stdout.write(
self.style.SUCCESS(
f"Merged '{source_name}' into '{target_name}': "
f"{payments} payment(s), {consumptions} consumption(s) reassigned. "
f"Source user deleted."
)
)
@@ -1,67 +0,0 @@
from datetime import date
from django.core.management import BaseCommand
from django.db import transaction
from gaehsnitz.models import Drink, Payment
PAYMENTS = [
# purpose, date, amount
("Toiletten", date(2026, 5, 6), 210.01),
("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 6, 8), 400.00),
("Baumarkt", date(2026, 5, 23), 194.00),
("Band: Six Good Years", date(2026, 6, 12), 150.00),
("Band: Melo-Komplott", date(2026, 6, 12), 100.00),
("Band: Mörtel", date(2026, 6, 12), 150.00),
("Band: Kotpiloten", date(2026, 6, 13), 150.00),
("Band: Knast", date(2026, 6, 13), 150.00),
("Band: Quast", date(2026, 6, 13), 300.00),
]
DRINKS = [
# name, category, crates, btl/crate, size, price/crate, deposit/crate, sale/btl
("Sternburg Export", "beer", 12, 20, 0.5, 9.49, 3.10, 2.00),
("Ur-Krostitzer", "beer", 5, 20, 0.5, 16.49, 3.10, 2.50),
("Budweiser", "beer", 5, 20, 0.5, 20.99, 3.10, 2.50),
("Altenburger Helles", "beer", 5, 20, 0.5, 13.99, 4.50, 2.50),
("Feldschl. Radler", "radler", 2, 20, 0.5, 14.99, 3.10, 2.50),
("Lübzer Grapef. 0,0", "alc_free_radler", 1, 20, 0.5, 17.99, 3.10, 2.50),
("Freiberger 0,0", "alc_free_beer", 4, 20, 0.5, 15.49, 3.10, 2.50),
("Club Mate", "soft", 2, 20, 0.5, 17.49, 4.50, 2.50),
("Vita Cola", "soft", 2, 12, 1.0, 10.99, 3.30, 2.50),
("Paulaner Spezi", "soft", 2, 20, 0.5, 17.99, 3.10, 2.50),
("Wasser", "water", 10, 12, 1.0, 5.99, 3.30, 1.50),
("Sekty Drink", "sekt", 0, 1, 0.33, 0.00, 0.00, 3.50),
]
class Command(BaseCommand):
help = "Seed Drink and Payment rows for the 2026 festival."
@transaction.atomic
def handle(self, *args, **options):
for purpose, day, amount in PAYMENTS:
obj, created = Payment.objects.update_or_create(
purpose=purpose,
date=day,
defaults={"amount": amount},
)
self.stdout.write(f"{'created' if created else 'updated'}: {obj.purpose} ({obj.date})")
for name, category, crates, btl, size, price, deposit, sale in DRINKS:
obj, created = Drink.objects.update_or_create(
name=name,
year=2026,
defaults={
"category": category,
"crates_ordered": crates,
"crates_purchased": crates,
"crates_returned": 0,
"purchase_price_per_crate": price,
"deposit_per_crate": deposit,
"bottles_per_crate": btl,
"bottle_size": size,
"sale_price_per_bottle": sale,
},
)
self.stdout.write(f"{'created' if created else 'updated'}: {obj.name}")
@@ -0,0 +1,19 @@
from django.contrib.auth import get_user_model
from django.core.management import BaseCommand
from django.db.models import Sum
from gaehsnitz.models import Payment, Donation
from gaehsnitz.templatetags.money import euro
User = get_user_model()
class Command(BaseCommand):
def handle(self, *args, **options):
all_donations_sum = Donation.objects.all().aggregate(sum=Sum("amount"))["sum"]
print(f"Alle Spenden/Zahlungen: {euro(all_donations_sum)}")
all_payments_sum = Payment.objects.all().aggregate(sum=Sum("amount"))["sum"]
print(f"Alle Ausgaben: {euro(all_payments_sum)}")
balance = all_donations_sum - all_payments_sum
print("-------------------------")
print(f"Bilanz: {euro(balance)}")
@@ -1,39 +0,0 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from gaehsnitz.models import Drink
# Actual crates bought from supplier (all others were returned full, no cost)
# (name, crates_ordered, crates_purchased)
# crates_returned = crates_purchased (all empties back, full deposit refunded)
ACTUAL_CRATES = [
("Sternburg Export", 12, 11),
("Ur-Krostitzer", 5, 2),
("Budweiser", 5, 2),
("Altenburger Helles", 5, 5),
("Feldschl. Radler", 2, 1),
("Lübzer Grapef. 0,0", 1, 1),
("Freiberger 0,0", 4, 3),
("Club Mate", 2, 2),
("Vita Cola", 2, 1),
("Paulaner Spezi", 2, 2),
("Wasser", 10, 5),
]
class Command(BaseCommand):
help = "Update 2026 drink crates to actual purchased amounts"
def handle(self, *args, **options):
with transaction.atomic():
for name, ordered, purchased in ACTUAL_CRATES:
updated = Drink.objects.filter(name=name, year=2026).update(
crates_ordered=ordered,
crates_purchased=purchased,
crates_returned=purchased,
)
if updated:
self.stdout.write(f" {name}: ordered={ordered}, purchased={purchased}, returned={purchased}")
else:
self.stdout.write(self.style.WARNING(f" NOT FOUND: {name}"))
self.stdout.write(self.style.SUCCESS("Done."))
@@ -0,0 +1,23 @@
from django.contrib.auth import get_user_model
from django.core.management import BaseCommand
from django.db.models import Sum
from gaehsnitz.models import Drink
from gaehsnitz.templatetags.money import euro
User = get_user_model()
class Command(BaseCommand):
def handle(self, *args, **options):
id_to_name = {d.id: d.name for d in Drink.objects.all()}
for user in User.objects.all().order_by("username"):
to_pay = user.consumed_drinks_price
if to_pay != 0:
paid_consumption = user.consumption_list.filter(for_free=False)
drink_list = []
for drink_dict in paid_consumption.values("drink_id").annotate(amount=Sum("amount")):
name = id_to_name[drink_dict["drink_id"]]
amount = drink_dict["amount"]
drink_list.append(f"{amount}x {name}")
print(f"{user.username.capitalize()}: {euro(to_pay)} ({", ".join(drink_list)})")
+56 -151
View File
@@ -10,189 +10,94 @@ import gaehsnitz.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name="User",
name='User',
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("password", models.CharField(max_length=128, verbose_name="password")),
("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={"unique": "A user with that username already exists."},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name="username",
),
),
("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")),
("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")),
("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
('id', models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Drink",
name='Drink',
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=32, unique=True)),
(
"crates_ordered",
models.PositiveSmallIntegerField(
help_text="just informational to see how good we planned, not the actual consumed/paid drinks"
),
),
("crates_purchased", models.PositiveSmallIntegerField()),
("crates_returned", models.PositiveSmallIntegerField()),
("purchase_price_per_crate", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
("deposit_per_crate", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
("bottles_per_crate", models.PositiveSmallIntegerField()),
("bottle_size", models.FloatField()),
("sale_price_per_bottle", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
('id', models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=32, unique=True)),
('crates_ordered', models.PositiveSmallIntegerField(help_text='just informational to see how good we planned, not the actual consumed/paid drinks')),
('crates_purchased', models.PositiveSmallIntegerField()),
('crates_returned', models.PositiveSmallIntegerField()),
('purchase_price_per_crate', gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
('deposit_per_crate', gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
('bottles_per_crate', models.PositiveSmallIntegerField()),
('bottle_size', models.FloatField()),
('sale_price_per_bottle', gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
],
),
migrations.CreateModel(
name="Payment",
name='Payment',
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("purpose", models.CharField(max_length=64)),
("date", models.DateField()),
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
(
"from_user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="payments",
related_query_name="payment",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('purpose', models.CharField(max_length=64)),
('date', models.DateField()),
('amount', gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
('from_user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', related_query_name='payment', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name="Payback",
name='Payback',
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("date", models.DateField()),
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
("note", models.CharField(blank=True, default="", max_length=64)),
(
"to_user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="paybacks",
related_query_name="payback",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('amount', gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
('note', models.CharField(blank=True, default='', max_length=64)),
('to_user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='paybacks', related_query_name='payback', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name="Donation",
name='Donation',
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("date", models.DateField()),
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
("note", models.CharField(blank=True, default="", max_length=64)),
(
"from_user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="donations",
related_query_name="donation",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('amount', gaehsnitz.models.PriceField(decimal_places=2, max_digits=6)),
('note', models.CharField(blank=True, default='', max_length=64)),
('from_user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='donations', related_query_name='donation', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name="Consumption",
name='Consumption',
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("amount", models.PositiveSmallIntegerField()),
("day", models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa")])),
("for_free", models.BooleanField(default=False)),
(
"drink",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
to="gaehsnitz.drink",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.PositiveSmallIntegerField()),
('day', models.PositiveSmallIntegerField(choices=[(1, 'Do'), (2, 'Fr'), (3, 'Sa')])),
('for_free', models.BooleanField(default=False)),
('drink', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consumption_list', related_query_name='consumption', to='gaehsnitz.drink')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consumption_list', related_query_name='consumption', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -4,25 +4,26 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0001_initial"),
('gaehsnitz', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name="donation",
name="from_user",
model_name='donation',
name='from_user',
),
migrations.RemoveField(
model_name="payment",
name="from_user",
model_name='payment',
name='from_user',
),
migrations.AlterField(
model_name="consumption",
name="day",
field=models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")]),
model_name='consumption',
name='day',
field=models.PositiveSmallIntegerField(choices=[(1, 'Do'), (2, 'Fr'), (3, 'Sa'), (4, 'So')]),
),
migrations.DeleteModel(
name="Payback",
name='Payback',
),
]
@@ -1,22 +0,0 @@
# Generated by Django 6.0.5 on 2026-05-14 09:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0002_remove_donation_from_user_remove_payment_from_user_and_more"),
]
operations = [
migrations.AddField(
model_name="consumption",
name="created_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="user",
name="pin",
field=models.CharField(blank=True, default="", max_length=128),
),
]
-26
View File
@@ -1,26 +0,0 @@
# Generated by Django 6.0.5 on 2026-05-14 18:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0003_consumption_created_at_user_pin"),
]
operations = [
migrations.AddField(
model_name="drink",
name="year",
field=models.PositiveSmallIntegerField(default=2024),
),
migrations.AlterField(
model_name="drink",
name="name",
field=models.CharField(max_length=32),
),
migrations.AlterUniqueTogether(
name="drink",
unique_together={("name", "year")},
),
]
@@ -1,167 +0,0 @@
# Generated by Django 6.0.5 on 2026-05-14 19:15
import django.db.models.deletion
import gaehsnitz.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0004_drink_year"),
]
operations = [
migrations.AlterModelOptions(
name="consumption",
options={"verbose_name": "Konsum", "verbose_name_plural": "Konsum"},
),
migrations.AlterModelOptions(
name="donation",
options={"verbose_name": "Spende", "verbose_name_plural": "Spenden"},
),
migrations.AlterModelOptions(
name="drink",
options={"verbose_name": "Getränk", "verbose_name_plural": "Getränke"},
),
migrations.AlterModelOptions(
name="payment",
options={"verbose_name": "Ausgabe", "verbose_name_plural": "Ausgaben"},
),
migrations.AlterModelOptions(
name="user",
options={"verbose_name": "Benutzer", "verbose_name_plural": "Benutzer"},
),
migrations.AlterField(
model_name="consumption",
name="amount",
field=models.PositiveSmallIntegerField(verbose_name="Anzahl"),
),
migrations.AlterField(
model_name="consumption",
name="created_at",
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name="Gebucht am"),
),
migrations.AlterField(
model_name="consumption",
name="day",
field=models.PositiveSmallIntegerField(
choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")], verbose_name="Tag"
),
),
migrations.AlterField(
model_name="consumption",
name="drink",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
to="gaehsnitz.drink",
verbose_name="Getränk",
),
),
migrations.AlterField(
model_name="consumption",
name="for_free",
field=models.BooleanField(default=False, verbose_name="Gratis"),
),
migrations.AlterField(
model_name="consumption",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
to=settings.AUTH_USER_MODEL,
verbose_name="Benutzer",
),
),
migrations.AlterField(
model_name="donation",
name="amount",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
),
migrations.AlterField(
model_name="donation",
name="date",
field=models.DateField(verbose_name="Datum"),
),
migrations.AlterField(
model_name="donation",
name="note",
field=models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz"),
),
migrations.AlterField(
model_name="drink",
name="bottle_size",
field=models.FloatField(verbose_name="Flaschengröße (l)"),
),
migrations.AlterField(
model_name="drink",
name="bottles_per_crate",
field=models.PositiveSmallIntegerField(verbose_name="Flaschen pro Kasten"),
),
migrations.AlterField(
model_name="drink",
name="crates_ordered",
field=models.PositiveSmallIntegerField(
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
verbose_name="Kästen bestellt",
),
),
migrations.AlterField(
model_name="drink",
name="crates_purchased",
field=models.PositiveSmallIntegerField(verbose_name="Kästen gekauft"),
),
migrations.AlterField(
model_name="drink",
name="crates_returned",
field=models.PositiveSmallIntegerField(verbose_name="Kästen leer zurück"),
),
migrations.AlterField(
model_name="drink",
name="deposit_per_crate",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Pfand pro Kasten"),
),
migrations.AlterField(
model_name="drink",
name="name",
field=models.CharField(max_length=32, verbose_name="Name"),
),
migrations.AlterField(
model_name="drink",
name="purchase_price_per_crate",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Einkaufspreis pro Kasten"),
),
migrations.AlterField(
model_name="drink",
name="sale_price_per_bottle",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Verkaufspreis pro Flasche"),
),
migrations.AlterField(
model_name="drink",
name="year",
field=models.PositiveSmallIntegerField(default=2024, verbose_name="Jahr"),
),
migrations.AlterField(
model_name="payment",
name="amount",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
),
migrations.AlterField(
model_name="payment",
name="date",
field=models.DateField(verbose_name="Datum"),
),
migrations.AlterField(
model_name="payment",
name="purpose",
field=models.CharField(max_length=64, verbose_name="Zweck"),
),
migrations.AlterField(
model_name="user",
name="pin",
field=models.CharField(blank=True, default="", max_length=128, verbose_name="PIN"),
),
]
-51
View File
@@ -1,51 +0,0 @@
# Generated by Django 6.0.5 on 2026-05-14 19:37
import django.db.models.deletion
import gaehsnitz.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0005_alter_consumption_options_alter_donation_options_and_more"),
]
operations = [
migrations.CreateModel(
name="UserPayment",
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag")),
(
"method",
models.CharField(
choices=[
("cash", "Bar"),
("paypal", "PayPal"),
("bank", "Überweisung"),
("other", "Sonstiges"),
],
max_length=16,
verbose_name="Methode",
),
),
("note", models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz")),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Bezahlt am")),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_payments",
related_query_name="user_payment",
to=settings.AUTH_USER_MODEL,
verbose_name="Benutzer",
),
),
],
options={
"verbose_name": "Zahlung",
"verbose_name_plural": "Zahlungen",
},
),
]
@@ -1,28 +0,0 @@
# Generated by Django 6.0.5 on 2026-05-14 20:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0006_user_payment"),
]
operations = [
migrations.AddField(
model_name="drink",
name="category",
field=models.CharField(
choices=[
("beer", "Bier"),
("radler", "Radler"),
("alc_free_beer", "Bier alkoholfrei"),
("soft", "Softdrink"),
("water", "Wasser"),
],
default="beer",
max_length=16,
verbose_name="Kategorie",
),
),
]
@@ -1,29 +0,0 @@
# Generated by Django 6.0.5 on 2026-05-14 20:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0007_drink_category"),
]
operations = [
migrations.AlterField(
model_name="drink",
name="category",
field=models.CharField(
choices=[
("beer", "Bier"),
("radler", "Radler"),
("alc_free_beer", "Bier alkoholfrei"),
("alc_free_radler", "Radler alkoholfrei"),
("soft", "Softdrink"),
("water", "Wasser"),
],
default="beer",
max_length=16,
verbose_name="Kategorie",
),
),
]
@@ -1,27 +0,0 @@
from django.db import migrations
ANONYMOUS_USERNAME = "anonym"
def create_anonymous(apps, schema_editor):
User = apps.get_model("gaehsnitz", "User")
User.objects.get_or_create(
username=ANONYMOUS_USERNAME,
defaults={"pin": "", "is_staff": False, "is_superuser": False},
)
def delete_anonymous(apps, schema_editor):
User = apps.get_model("gaehsnitz", "User")
User.objects.filter(username=ANONYMOUS_USERNAME).delete()
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0008_alter_drink_category"),
]
operations = [
migrations.RunPython(create_anonymous, delete_anonymous),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.5 on 2026-06-09 13:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gaehsnitz', '0009_anonymous_user'),
]
operations = [
migrations.AlterField(
model_name='drink',
name='category',
field=models.CharField(choices=[('beer', 'Bier'), ('radler', 'Radler'), ('sekt', 'Sekt'), ('alc_free_beer', 'Bier alkoholfrei'), ('alc_free_radler', 'Radler alkoholfrei'), ('soft', 'Softdrink'), ('water', 'Wasser')], default='beer', max_length=16, verbose_name='Kategorie'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.6 on 2026-06-20 12:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gaehsnitz', '0010_add_sekt_category'),
]
operations = [
migrations.AddField(
model_name='payment',
name='method',
field=models.CharField(choices=[('cash', 'Bar'), ('card', 'EC-Karte'), ('paypal', 'PayPal'), ('bank', 'Überweisung')], default='paypal', max_length=16, verbose_name='Methode'),
),
]
+26 -148
View File
@@ -1,34 +1,16 @@
from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Sum, F
from django.utils import timezone
def current_year():
return timezone.now().year
class PriceField(models.DecimalField):
def __init__(self, verbose_name=None, name=None, **kwargs):
kwargs.update({"max_digits": 6, "decimal_places": 2})
super().__init__(verbose_name, name, **kwargs)
class User(AbstractUser):
pin = models.CharField("PIN", max_length=128, blank=True, default="")
class Meta:
verbose_name = "Benutzer"
verbose_name_plural = "Benutzer"
def set_pin(self, raw_pin):
self.pin = make_password(raw_pin)
def check_pin(self, raw_pin):
if not self.pin:
return False
return check_password(raw_pin, self.pin)
@property
def paid_drinks(self):
@@ -40,31 +22,16 @@ class User(AbstractUser):
@property
def consumed_drinks_price(self):
query = (
self.paid_drinks.filter(drink__year=current_year())
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
query = self.paid_drinks \
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")) \
.aggregate(sum=Sum("cost"))
)
return query["sum"] or 0
@property
def paid_amount(self):
query = self.user_payments.filter(created_at__year=current_year()).aggregate(sum=Sum("amount"))
return query["sum"] or 0
@property
def open_balance(self):
return self.consumed_drinks_price - self.paid_amount
class Donation(models.Model):
date = models.DateField("Datum")
amount = PriceField("Betrag")
note = models.CharField("Notiz", max_length=64, blank=True, default="")
class Meta:
verbose_name = "Spende"
verbose_name_plural = "Spenden"
date = models.DateField()
amount = PriceField()
note = models.CharField(max_length=64, blank=True, default="")
class Payment(models.Model):
@@ -73,59 +40,27 @@ class Payment(models.Model):
bands = 11, "Bands"
supply_purchase = 12, "Getränke-/Essenseinkauf"
class Method(models.TextChoices):
cash = "cash", "Bar"
card = "card", "EC-Karte"
paypal = "paypal", "PayPal"
bank = "bank", "Überweisung"
purpose = models.CharField(max_length=64)
date = models.DateField()
amount = PriceField()
purpose = models.CharField("Zweck", max_length=64)
date = models.DateField("Datum")
amount = PriceField("Betrag")
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 Category(models.TextChoices):
beer = "beer", "Bier"
radler = "radler", "Radler"
sekt = "sekt", "Sekt"
alc_free_beer = "alc_free_beer", "Bier alkoholfrei"
alc_free_radler = "alc_free_radler", "Radler alkoholfrei"
soft = "soft", "Softdrink"
water = "water", "Wasser"
name = models.CharField("Name", max_length=32)
year = models.PositiveSmallIntegerField("Jahr", default=2024)
category = models.CharField(
"Kategorie",
max_length=16,
choices=Category.choices,
default=Category.beer,
)
name = models.CharField(max_length=32, unique=True)
crates_ordered = models.PositiveSmallIntegerField(
"Kästen bestellt",
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
help_text="just informational to see how good we planned, not the actual consumed/paid drinks"
)
crates_purchased = models.PositiveSmallIntegerField("Kästen gekauft")
crates_returned = models.PositiveSmallIntegerField("Kästen leer zurück")
purchase_price_per_crate = PriceField("Einkaufspreis pro Kasten")
deposit_per_crate = PriceField("Pfand pro Kasten")
bottles_per_crate = models.PositiveSmallIntegerField("Flaschen pro Kasten")
bottle_size = models.FloatField("Flaschengröße (l)")
sale_price_per_bottle = PriceField("Verkaufspreis pro Flasche")
class Meta:
unique_together = (("name", "year"),)
verbose_name = "Getränk"
verbose_name_plural = "Getränke"
crates_purchased = models.PositiveSmallIntegerField()
crates_returned = models.PositiveSmallIntegerField()
purchase_price_per_crate = PriceField()
deposit_per_crate = PriceField()
bottles_per_crate = models.PositiveSmallIntegerField()
bottle_size = models.FloatField()
sale_price_per_bottle = PriceField()
def __str__(self):
return f"{self.name} {self.year}"
return self.name
@property
def bottles_total(self):
@@ -189,71 +124,14 @@ class Drink(models.Model):
def balance(self):
return self.sale_price_total - self.sales_purchase_value - self.giveaway_purchase_value
@property
def crates_full_returned(self):
return self.crates_ordered - self.crates_purchased
@property
def crates_remaining(self):
return self.crates_purchased - self.crates_returned
@property
def bottles_consumed(self):
return self.bottles_sold + self.bottles_given_away
@property
def bottles_remaining(self):
return self.bottles_total - self.bottles_consumed
@property
def remaining_purchase_value(self):
return self.bottles_remaining * self.purchase_price_per_bottle
class UserPayment(models.Model):
class Method(models.TextChoices):
cash = "cash", "Bar"
paypal = "paypal", "PayPal"
bank = "bank", "Überweisung"
other = "other", "Sonstiges"
user = models.ForeignKey(
verbose_name="Benutzer",
to=User,
on_delete=models.CASCADE,
related_name="user_payments",
related_query_name="user_payment",
)
amount = PriceField("Betrag")
method = models.CharField("Methode", max_length=16, choices=Method.choices)
note = models.CharField("Notiz", max_length=64, blank=True, default="")
created_at = models.DateTimeField("Bezahlt am", auto_now_add=True)
class Meta:
verbose_name = "Zahlung"
verbose_name_plural = "Zahlungen"
class Consumption(models.Model):
user = models.ForeignKey(
verbose_name="Benutzer",
to=User,
on_delete=models.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
)
to=User, on_delete=models.CASCADE,
related_name="consumption_list", related_query_name="consumption")
drink = models.ForeignKey(
verbose_name="Getränk",
to=Drink,
on_delete=models.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
)
amount = models.PositiveSmallIntegerField("Anzahl")
day = models.PositiveSmallIntegerField("Tag", choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
for_free = models.BooleanField("Gratis", default=False)
created_at = models.DateTimeField("Gebucht am", auto_now_add=True, null=True)
class Meta:
verbose_name = "Konsum"
verbose_name_plural = "Konsum"
to=Drink, on_delete=models.CASCADE,
related_name="consumption_list", related_query_name="consumption")
amount = models.PositiveSmallIntegerField()
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
for_free = models.BooleanField(default=False)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

+17 -58
View File
@@ -11,18 +11,18 @@ table, thead, tfoot, tr, td {
html, body {
width: 100%;
height: 100%;
font-size: 16px;
font-size: 14px;
}
@media only screen and (min-width: 600px) and (max-width: 899px) {
html, body {
font-size: 18px;
font-size: 16px;
}
}
@media only screen and (min-width: 900px) {
html, body {
font-size: 20px;
font-size: 18px;
}
}
@@ -42,15 +42,8 @@ body {
flex: 0 1 auto;
width: 95%;
max-width: 1200px;
margin: 8px;
}
#navi, #content {
padding-bottom: 24px;
}
#content {
margin-top: 0;
margin: 8px;
}
#navi, #content {
@@ -59,23 +52,22 @@ body {
#title {
margin-top: 24px;
padding-bottom: 18px;
text-align: center;
color: #FFCC77;
color: #CCEE66;
font-weight: bold;
}
h1 {
margin-bottom: 10px;
font-size: 1.6rem;
color: #EE9933;
text-shadow: 0 0 16px #CC6611;
color: #99EE33;
text-shadow: 0 0 16px #669933;
}
#navi {
padding: 8px 0;
text-align: center;
color: #885522;
color: #446622;
}
#navi a {
@@ -85,7 +77,7 @@ h1 {
a {
text-decoration: none;
color: #EE9933;
color: #99EE33;
transition: color 100ms;
}
@@ -111,7 +103,7 @@ h2 {
margin-bottom: 14px;
font-size: 1.3rem;
font-weight: normal;
color: #EE9933;
color: #99EE33;
}
h3 {
@@ -119,7 +111,7 @@ h3 {
margin-bottom: 8px;
font-size: 1.15rem;
font-weight: normal;
color: #FFCC77;
color: #CCEE66;
}
p, ul {
@@ -139,15 +131,15 @@ table {
}
thead {
color: #FFCC77;
color: #CCEE66;
}
.odd-row {
background-color: rgba(80, 40, 10, 0.4);
background-color: rgba(40, 60, 20, 0.4);
}
.even-row {
background-color: rgba(80, 40, 10, 0.1);
background-color: rgba(40, 60, 20, 0.1);
}
td {
@@ -158,7 +150,7 @@ td {
hr {
margin-top: 24px;
margin-bottom: 24px;
border: 3px solid #885522;
border: 3px solid #446622;
border-radius: 3px;
}
@@ -199,7 +191,7 @@ hr {
margin-top: 12px;
margin-bottom: 12px;
border: 2px solid;
border-image: linear-gradient(to right, rgb(238, 153, 51) 0%, rgba(238, 102, 34, 0) 100%) 1;
border-image: linear-gradient(to right, rgb(153, 238, 51) 0%, rgba(238, 153, 51, 0) 100%) 1;
overflow: hidden;
}
@@ -208,7 +200,7 @@ hr {
max-width: 160px;
max-height: 90px;
z-index: -1;
box-shadow: 0 0 30px #885522;
box-shadow: 0 0 30px #446622;
}
.bandbox p {
@@ -257,36 +249,3 @@ hr {
.marked {
color: #CC66EE;
}
#footer {
margin-top: 32px;
padding-bottom: 32px;
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 #content h2 { color: #D8DEF0; }
.archive #content h3 { color: #B8C0D8; }
.archive #content a { color: #B8C0D8; }
.archive #content thead { color: #B8C0D8; }
.archive #content .odd-row { background-color: rgba(180, 200, 255, 0.07); }
.archive #content .even-row { background-color: rgba(180, 200, 255, 0.02); }
.archive #content hr { border-color: #505870; }
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<text x="50%" y="54%" font-size="56" text-anchor="middle" dominant-baseline="central">🍺</text>
</svg>

Before

Width:  |  Height:  |  Size: 170 B

-770
View File
@@ -1,770 +0,0 @@
*, *::before, *::after {
box-sizing: border-box;
}
html, body, h1, h2, p, form, label, input, button {
margin: 0;
padding: 0;
border: none;
}
html, body {
width: 100%;
min-height: 100%;
font-size: 16px;
font-family: system-ui, -apple-system, sans-serif;
}
body {
background-color: #161616;
color: #EEEEEE;
padding: 24px 16px 48px;
display: flex;
flex-direction: column;
align-items: center;
}
main {
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 18px;
}
.site-name {
text-align: center;
color: #885522;
font-size: 0.95rem;
letter-spacing: 0.05em;
margin-bottom: -4px;
}
h1 {
font-size: 2.4rem;
font-weight: bold;
color: #EE9933;
text-shadow: 0 0 16px #CC6611;
text-align: center;
margin-bottom: 8px;
}
h2 {
font-size: 1.3rem;
font-weight: normal;
color: #FFCC77;
}
p {
line-height: 1.5rem;
color: #DDDDDD;
}
a {
color: #EE9933;
text-decoration: none;
}
a:hover, a:focus {
color: #EEEEEE;
}
.error {
color: #EE6622;
font-weight: bold;
}
form {
display: flex;
flex-direction: column;
gap: 20px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
color: #FFCC77;
font-size: 0.95rem;
}
input[type="text"],
input[type="number"],
select {
background-color: rgba(80, 40, 10, 0.4);
color: #EEEEEE;
border: 2px solid #885522;
border-radius: 6px;
padding: 14px 12px;
font-size: 1.1rem;
width: 100%;
min-height: 52px;
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
font-family: inherit;
}
select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path fill='%23EE9933' d='M0 0l6 8 6-8z'/></svg>");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 12px 8px;
padding-right: 40px;
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus {
outline: none;
border-color: #EE9933;
}
button {
background-color: #EE9933;
color: #161616;
font-size: 1.1rem;
font-weight: bold;
padding: 14px 20px;
border-radius: 6px;
cursor: pointer;
width: 100%;
min-height: 52px;
}
button:hover, button:focus {
background-color: #FFCC77;
}
button {
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 {
transform: scale(0.96);
}
.muted {
font-size: 0.9rem;
color: #AAAAAA;
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 {
font-size: 0.9rem;
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 {
font-size: 1.1rem;
font-weight: normal;
color: #FFCC77;
margin-bottom: 10px;
}
section {
display: flex;
flex-direction: column;
gap: 10px;
}
.total-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 18px;
background-color: rgba(80, 40, 10, 0.4);
border: 2px solid #885522;
border-radius: 8px;
}
.total-label {
color: #FFCC77;
font-size: 0.95rem;
}
.total-value {
color: #EE9933;
font-size: 2rem;
font-weight: bold;
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 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.drink-grid form {
margin: 0;
}
.drink-btn {
width: 100%;
aspect-ratio: 3 / 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px;
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 {
font-size: 1.6rem;
font-weight: bold;
}
.drink-name {
font-size: 1rem;
font-weight: bold;
text-align: center;
word-break: break-word;
}
.drink-price {
font-size: 0.85rem;
font-weight: normal;
opacity: 0.75;
}
.history {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.history li {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
background-color: rgba(80, 40, 10, 0.2);
border-radius: 4px;
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) {
background-color: rgba(80, 40, 10, 0.35);
}
.hist-when {
color: #FFCC77;
white-space: nowrap;
}
.hist-price {
color: #DDDDDD;
white-space: nowrap;
}
.btn-secondary {
background-color: transparent;
color: #885522;
border: 2px solid #885522;
}
.btn-secondary:hover, .btn-secondary:focus {
background-color: rgba(80, 40, 10, 0.4);
color: #FFCC77;
}
.bottom-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
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 {
background-color: rgba(238, 153, 51, 0.15);
border: 2px solid #EE9933;
color: #FFCC77;
padding: 12px 16px;
border-radius: 6px;
text-align: center;
font-weight: bold;
overflow: hidden;
max-height: 200px;
animation: toast-in 200ms ease-out, toast-collapse 800ms ease-in-out 5s forwards;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-collapse {
0% {
opacity: 1;
max-height: 200px;
padding-top: 12px;
padding-bottom: 12px;
margin-bottom: 0;
border-width: 2px;
}
40% {
opacity: 0;
max-height: 200px;
padding-top: 12px;
padding-bottom: 12px;
border-width: 2px;
}
100% {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: calc(-1 * var(--gap, 18px));
border-width: 0;
}
}
.day-group {
margin-bottom: 14px;
}
.day-heading {
font-size: 0.95rem;
font-weight: bold;
color: #EE9933;
margin: 0 0 6px 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.empty-state {
text-align: center;
padding: 24px 12px;
background-color: rgba(80, 40, 10, 0.15);
border: 1px dashed #885522;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-emoji {
font-size: 2.5rem;
margin: 0 0 8px;
line-height: 1;
}
.empty-state p {
margin: 0;
}
.link-row {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.link-btn {
display: block;
text-align: center;
background-color: #EE9933;
color: #161616;
font-weight: bold;
padding: 14px 20px;
border-radius: 6px;
transition: transform 80ms ease, background-color 100ms ease;
}
.link-btn:hover, .link-btn:focus {
background-color: #FFCC77;
color: #161616;
}
.link-btn:active {
transform: scale(0.96);
}
.link-btn-secondary {
background-color: transparent;
color: #885522;
border: 2px solid #885522;
}
.link-btn-secondary:hover, .link-btn-secondary:focus {
background-color: rgba(80, 40, 10, 0.4);
color: #FFCC77;
}
.progress-wrap {
position: relative;
width: 100%;
height: 28px;
background-color: rgba(80, 40, 10, 0.4);
border: 2px solid #885522;
border-radius: 6px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: #EE9933;
box-shadow: 0 0 12px #CC6611;
transition: width 200ms ease;
}
.progress-label {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: #161616;
font-weight: bold;
font-size: 0.95rem;
mix-blend-mode: screen;
}
.dash-row {
grid-template-columns: 1fr auto auto;
}
.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;
}
-986
View File
@@ -1,986 +0,0 @@
import math
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth import get_user_model
from django.db.models import Case, CharField, Count, F, IntegerField, Sum, Value, When
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from django.views.decorators.http import require_http_methods
from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
User = get_user_model()
ANONYMOUS_USERNAME = "anonym"
BERLIN = ZoneInfo("Europe/Berlin")
# Festival window: 2026-05-30 10:00 2026-06-14 22:00 Berlin time.
BOOKING_START = datetime(2026, 5, 30, 10, 0, 0, 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_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():
return timezone.now().astimezone(BERLIN)
def _phase():
if not settings.PRODUCTION:
return "booking"
now = _now()
if now < BOOKING_START:
return "before"
if now <= BOOKING_END:
return "booking"
return "closed"
def _require_open(request):
"""Redirect to closed page when tool is not in booking phase."""
phase = _phase()
if phase in ("before", "closed"):
return HttpResponseRedirect(reverse("suff:closed")), None
return None, phase
def _current_festival_day():
# 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)
def _normalize_name(raw):
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):
year = current_year()
consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id")
total = (
user.paid_drinks.filter(drink__year=year)
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
.aggregate(total=Sum("cost"))["total"]
or 0
)
payments = user.user_payments.filter(created_at__year=year).order_by("-created_at")
paid = payments.aggregate(sum=Sum("amount"))["sum"] or 0
return {
"tab_user": user,
"consumption_list": consumption,
"total": total,
"paid": paid,
"open_balance": total - paid,
"user_payments": payments,
"payment_methods": [(m, l) for m, l in UserPayment.Method.choices if m != "other"],
}
@require_http_methods(["GET", "POST"])
def name_view(request):
redirect, phase = _require_open(request)
if redirect:
return redirect
if request.user.is_authenticated:
return HttpResponseRedirect(reverse("suff:me"))
error = None
if request.method == "POST":
raw_name = request.POST.get("name", "")
username = _normalize_name(raw_name)
if not username or (error := _username_error(username)):
error = error or "Bitte einen Namen eingeben."
else:
try:
existing = User.objects.get(username=username)
except User.DoesNotExist:
existing = None
if existing is None:
return render(
request,
"suff/party_over.html",
{"phase": phase, "username": username},
)
if not existing.pin:
if _user_has_activity(existing):
return render(
request,
"suff/no_pin.html",
{"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_mode"] = "login"
return HttpResponseRedirect(reverse("suff:pin"))
return render(request, "suff/name.html", {"phase": phase, "error": error})
@require_http_methods(["GET", "POST"])
def pin_view(request):
redirect, phase = _require_open(request)
if redirect:
return redirect
username = request.session.get("pending_username")
mode = request.session.get("pending_mode")
if not username or mode not in ("create", "login", "claim"):
return HttpResponseRedirect(reverse("suff:name"))
error = None
if request.method == "POST":
pin = request.POST.get("pin", "")
if error := _pin_error(pin):
pass
elif mode == "create":
if User.objects.filter(username=username).exists():
error = "Name bereits vergeben."
return HttpResponseRedirect(reverse("suff:name"))
user = User(username=username)
user.set_pin(pin)
user.save()
authed = authenticate(request, username=username, pin=pin)
if authed is None:
error = "Login nach Anlegen fehlgeschlagen."
else:
login(request, authed, backend="gaehsnitz.auth_backends.PinBackend")
request.session.pop("pending_username", None)
request.session.pop("pending_mode", None)
return HttpResponseRedirect(reverse("suff:me"))
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:
authed = authenticate(request, username=username, pin=pin)
if authed is None:
error = "Falsche PIN."
else:
login(request, authed, backend="gaehsnitz.auth_backends.PinBackend")
request.session.pop("pending_username", None)
request.session.pop("pending_mode", None)
return HttpResponseRedirect(reverse("suff:me"))
return render(
request,
"suff/pin.html",
{
"phase": phase,
"error": error,
"mode": mode,
"username": username,
},
)
@login_required
@require_http_methods(["GET", "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
@require_http_methods(["GET"])
def me_view(request):
redirect, phase = _require_open(request)
if redirect:
return redirect
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(request.user)
context.update(
{
"phase": phase,
"drinks": drinks,
"current_day": _current_festival_day(),
"booked_drink": booked_drink,
"paid_toast": request.GET.get("paid") == "1",
}
)
return render(request, "suff/me.html", context)
@login_required
@require_http_methods(["POST"])
def book_view(request):
redirect, phase = _require_open(request)
if redirect:
return redirect
if phase != "booking":
raise Http404
if _is_anonymous(request.user):
raise Http404
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:me"))
Consumption.objects.create(
user=request.user,
drink=drink,
amount=1,
day=_current_festival_day(),
for_free=for_free,
)
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"])
def logout_view(request):
logout(request)
return HttpResponseRedirect(reverse("suff:name"))
@require_http_methods(["GET"])
def closed_view(request):
phase = _phase()
if phase == "booking":
return HttpResponseRedirect(reverse("suff:name"))
return render(request, "suff/closed.html", {"phase": phase, "booking_start": BOOKING_START})
-55
View File
@@ -1,55 +0,0 @@
from django.urls import path
from gaehsnitz.suff import (
book_view,
change_pin_view,
closed_view,
dashboard_view,
delete_consumption_view,
delete_payment_view,
logout_view,
me_view,
name_view,
pay_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"
urlpatterns = [
path("", name_view, name="name"),
path("closed/", closed_view, name="closed"),
path("pin/", pin_view, name="pin"),
path("me/", me_view, name="me"),
path("me/change-pin/", change_pin_view, name="change_pin"),
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"),
]
@@ -1,23 +0,0 @@
{% extends "admin/base_site.html" %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Home</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:gaehsnitz_user_changelist' %}">Users</a>
&rsaquo; <a href="{% url 'admin:gaehsnitz_user_change' original.pk %}">{{ original }}</a>
&rsaquo; PIN setzen
</div>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<div>
{{ form.as_p }}
</div>
<div class="submit-row">
<input type="submit" value="PIN setzen" class="default" />
</div>
</form>
{% endblock %}
@@ -1,255 +0,0 @@
{% extends "gaehsnitz/base.html" %}
{% block body_class %}archive{% endblock %}
{% block content %}
<h2>Archiv 2022</h2>
<p>25. - 28. August 2022</p>
<hr>
<h2>News</h2>
<h3>11.09. - Aftermath</h3>
<p>
Tja Leute, wie das immer so ist: lang geplant, kurz gefeiert... aber es war wieder absolut Klasse! Die bunte
Band-Mischung hat richtig gefetzt, die Versorgung hat der hohen Trichterfrequenz bei weitem standgehalten, aber
vor allem war mal wieder ein ganzer Haufen lieber Leute am Start,
<span class="accent">Danke dafür! ❤</span>
</p>
<h3>15.08. - TL;DR</h3>
<p>
In anderthalb Wochen ist es so weit, deshalb hier kurz das Wichtigste zusammengefasst:
</p>
<ul>
<li>Checkt die Anreise und bildet Fahrgemeinschaften.</li>
<li>Bringt gern Leute mit, aber fragt uns bitte vorher. Außerdem nicht im Internet posten - die Veranstaltung ist privat.</li>
<li>Bitte helft uns mit eurer Eintrittsspende und noch besser vorab mit einem Suff-Kredit. ❤</li>
<li>Suff aller Art gibt's genug und zu fairen Preisen.</li>
<li>Das Essen könnte knapp werden. Bitte bringt eine Kleinigkeit mit (Aufstriche, Salate, Käse etc.).</li>
<li>Draußen gibt es kein Trinkwasser - bringt Wasserkanister mit.</li>
</ul>
<h3>17.07. - .. es wird!</h3>
<p>
Wir haben uns dieses Wochenende noch einmal ins Zeug gelegt: einen Drehstrom-Anschluss verkabelt, die Bar neu
lackiert, den Drum-Riser gebaut, die Laube aufgeräumt und mal wieder überall gemäht.
</p>
<h3>27.05. - Himmelfahrtsaktion</h3>
<p>Am Wochenende nach Himmelfahrt haben wir schonmal gaaanz viel geschafft:</p>
<ul>
<li>Rasen gemäht und einige Büsche verschnitten</li>
<li>die Bar stabilisiert</li>
<li>die Bühnenfläche vermessen, mit Platten begrenzt und geebnet</li>
<li>die Hauptfläche weiter begradigt</li>
<li>sämtliches Holz am Grundstücksrand gestapelt</li>
</ul>
<hr>
<h2>Programm</h2>
<ul>
<li>Donnerstag 25.: Aufbau</li>
<li>Freitag 26.: Quast, Direct Juice</li>
<li>Samstag 27.: Melo-Komplott, The Residudes, RATs</li>
<li>Sonntag 28.: Abbau</li>
</ul>
<hr>
<h2>Von A bis Z</h2>
<p>
Wie letztes Jahr schon werden wir womöglich demnächst mal auswerten, wie die Dinge im Detail so gelaufen sind.
Dann wird hier alles kommentiert, damit wir uns das bis nächstes Jahr merken können. ;)
</p>
<h3 id="address">Adresse &amp; Anfahrt</h3>
<p>
Die Sause findet im Garten auf dem Grundstück von Tobis Eltern statt.
</p>
<p>
Adresse: Gähsnitzer Ring 9, 04618 Nobitz<br>
Koordinaten: 50.9070, 12.5465
</p>
<p>
Bekanntlich kann man ja im August noch für 9,-€ die Welt bereisen, daher empfiehlt sich die gemeinsame Anreise
<span class="accent">mit dem Zug</span>. Die S5X fährt stündlich um :40 von Leipzig Hbf und kommt um :25 in
Gößnitz an. Von dort sind es noch etwa 11km.
</p>
<h3 id="event">Art der Veranstaltung</h3>
<p>
Das ganze ist immer noch eine private Gartenparty, keine angemeldete Veranstaltung! Das bringt folgende Regeln
mit sich:
</p>
<ul>
<li>Bitte macht <span class="accent">keine öffentliche Werbung</span>, vor allem nicht im Internet.</li>
<li>Ihr könnt Freund*innen mitbringen, aber bitte fragt uns vorher mal kurz.</li>
<li>Achtet auf das Wohl der Anwohner*innen.</li>
<li>Live-Mucke sollte bis 22:00 durch sein. Danach können wir immer noch Konservenmucke hören, aber am besten nur auf 70% Lautstärke. ;)</li>
</ul>
<h3 id="food">Essen</h3>
<p>
Hauptmahlzeiten: Am Donnerstag werden wir wohl den Grill anhauen. Am Freitag und Samstag wird es jeweils eine
vegane Hauptmahlzeit aus dem großen Feuertopf geben.
</p>
<p>
Rundherum bitten wir euch, euch <span class="accent">selbst etwas zu essen mitzubringen</span> - Aufstriche,
Salate, Käse etc. in verschließbaren Behältern. Wir besorgen Brot/Brötchen und einen Grundvorrat.
</p>
<h3 id="drinks">Getränke &amp; Bar</h3>
<p>
Ein lokaler Getränkehändler bringt uns 'nen LKW voll Suff vorbei - es gibt Bier, Radler, Wasser, Mate und Cola.
Alles wird in Flaschen verkauft, seid deshalb bitte besonders vorsichtig, dass nichts zu Bruch geht!
</p>
<h3>Mucke &amp; Playlists</h3>
<p>
DJ Hymr wird einen Haufen Mucke bereithalten. Darüber hinaus gibt es Klinkenparty, allerdings streng limitiert
von den Geschmäckern der Veranstalter. :P
</p>
<h3>Parken &amp; Zelten</h3>
<p>
Der Großteil des Gartens (hinter der Bühne) dient als Zeltplatz. Es gibt einige Parkplätze rund um das
Grundstück und die Garage.
</p>
<h3 id="sanitary">Sanitär</h3>
<p>
<span class="accent">Es gibt kein Wasser auf dem Gelände!</span> Wenn ihr die Möglichkeit habt, bringt bitte
größtmögliche <span class="accent">Wasserkanister</span> (auch leer) mit. Es wird Dixi-Toiletten mit
Desinfektionsmittel und einem mobilen Waschbecken geben.
</p>
<h3>Wetter</h3>
<p>
Wir haben viele Pavillons, um auch bei Regen noch einigermaßen gute Laune zu wahren. Haltet Abstand mit Feuer
zur Vegetation und werft keine Kippen in die Gegend!
</p>
<hr>
<h2>Bühne &amp; Technik</h2>
<h3>Backline</h3>
<ul>
<li>Bassbox: Markbass 4x10", 4 Ohm, Speakon</li>
<li>Gitarrenbox: JetCity 2x12", 8 Ohm, Klinke</li>
<li>Drums: Pearl, Bassdrum, 3 Toms, Stative für Snare, HiHat, Crash, Ride</li>
<li>(Bass-Amp: Markbass Little Mark Tube 800 darf mitgenutzt werden)</li>
<li>(Gitarrenkombo: Blackstar HT-5 darf mitbenutzt werden)</li>
</ul>
<h3>Mikrofonierung</h3>
<ul>
<li>3 Gesangsmikros: SM58, PG58 und noch 'n billiges</li>
<li>3 Amp/Instrumenten-Mikros (Bass sowieso lieber via DI)</li>
<li>Drum-Mikros: t.bone Beta BD 500 und ein Set für alles andere</li>
<li>1 DI-Box</li>
<li>6 große Mikro-Stative, 3 kleine Mikro-Stative</li>
</ul>
<h3>Monitoring</h3>
<ul>
<li>1 Aktivbox neben den Drums</li>
<li>2 Front-Monitore (RATs)</li>
</ul>
<h3>PA</h3>
<ul>
<li>Mischpult &amp; Stagebox: Behringer X-Air 18</li>
<li>Beschallung: 2 aktive Subwoofer, 2 passive Hochtöner + Amp, Stative</li>
</ul>
<hr>
<h2>Finanzen</h2>
<p>
Da eine offene Kommunikation irgendwie zu dem ganzen unkommerziellen DIY-Gedöhns dazugehört, findet ihr hier
ganz transparent eine Übersicht, von wo nach wo eigentlich wie viel Kohle geflossen ist.
Nur keine Klarnamen. ;)
</p>
<h3>Zusammenfassung</h3>
<table>
<tr class="odd-row">
<td>Summe aller Spenden/Zahlungen</td>
<td>1.130 €</td>
</tr>
<tr class="even-row">
<td>Summe aller Ausgaben</td>
<td>1.250 €</td>
</tr>
<tr class="odd-row">
<td>Stand</td>
<td class="accent">-120 €</td>
</tr>
</table>
<h3>Ausgaben</h3>
<table>
<thead>
<tr class="odd-row">
<td>Zweck</td>
<td>Betrag</td>
<td>Datum</td>
</tr>
</thead>
<tr class="even-row">
<td>Baumarkt/Elektrik</td>
<td>70 €</td>
<td>16.07.</td>
</tr>
<tr class="odd-row">
<td>Baumarkt/Elektrik</td>
<td>27 €</td>
<td>18.07.</td>
</tr>
<tr class="even-row">
<td>Dixis</td>
<td>328 €</td>
<td>08.08.</td>
</tr>
<tr class="odd-row">
<td>Getränke/Anzahlung</td>
<td>400 €</td>
<td>22.08.</td>
</tr>
<tr class="even-row">
<td>Supermarkt/Essen</td>
<td>193 €</td>
<td>25.08.</td>
</tr>
<tr class="odd-row">
<td>Getränke/Abrechnung</td>
<td>122 €</td>
<td>01.09.</td>
</tr>
<tr class="even-row">
<td>Bands</td>
<td>110 €</td>
<td>-</td>
</tr>
</table>
<p>
Details zu den Spenden und Rückzahlungen an die Leute, die die Ausgaben geleistet haben,
lassen wir hier erstmal weg. Wer's ganz genau wissen will, kann ja fragen.
</p>
<p>
Nicht aufgelistet sind kurzfristige Dinge für die Vorbereitungsaktionen, also z.B. Suff und Sprit, den wir für
die Arbeitseinsätze gekauft und auch direkt vernichtet haben. Danke an dieser Stelle nochmal allen für die
jeweiligen Einkäufe und die unbezahlbare Arbeitskraft!
</p>
{% endblock %}
@@ -1,273 +0,0 @@
{% extends "gaehsnitz/base.html" %}
{% load static %}
{% load money %}
{% block body_class %}archive{% endblock %}
{% block content %}
<h2>Archiv 2024</h2>
<p>06. - 09. Juni 2024</p>
<hr>
<h2>News</h2>
<h3>06.05. - Hochoffizielle Einladung</h3>
<p>
Der Plan für Gähsnitz dieses Jahr steht und zur Motivation gibt's diesmal auch 'n digitales Plakat. 🍾
</p>
<a href="{% static 'gaehsnitz/plakat-2024.jpg' %}">
<img id="flyer" src="{% static 'gaehsnitz/plakat-2024-small.jpg' %}" alt="Plakat">
</a>
<p>
Fühlt euch alle eingeladen, am 6., 7., spätestens aber am 8. Juni bei uns im Garten vorbeizuschneien. Es gibt
bunt gemischte Live-Musik, Getränke für'n schmalen Taler, echte Landluft und was sonst noch alles auf dem Bild
zu finden ist.
</p>
<p>
Mehr Details gibt's nochmal 1-2 Wochen vorher - bis dahin einfach den Termin freihalten, Urlaub nehmen oder
kündigen, und am besten auch schonmal üben, wie man ganz schnell Pavillons aufbaut und Wurfzelte wieder in ihre
Verpackung kriegt.
</p>
<p>
Bringt gern noch mehr liebe Leute mit, aber bitte nich bei MySpace posten oder Plakate aufhängen - ist immer
noch 'ne Privatveranstaltung. ;)
</p>
<h3>04.04. - Der Winterschlaf ist vorbei</h3>
<p>
Der Termin für unsere diesjährige Gartensause steht ja schon seit letztem Jahr fest:
<span class="accent">06. - 09. Juni</span>. Sogar die Dixis sind schon vorbestellt! Den größten Fokus (und die
meiste Live-Mucke) wollen wir dieses Mal auf Samstag (08.06.) legen, aber mehr dazu später.
</p>
<p>
Über Ostern waren haben wir uns mal das Gelände angeschaut und uns gefreut, dass sich in anderthalb Jahren gar
nicht mal so viel verändert hat - sogar das Drum-Podest ist noch nutzbar. Trotzdem gibt's natürlich alle Hände
voll zu tun, um den Garten wieder festivaltauglich zu machen und zu konservieren.
</p>
<p>
Wir wollen über <span class="accent">Himmelfahrt</span> (09. - 12.05.) nochmal hinfahren und könnten dafür auch
so viele helfende Hände wie möglich gebrauchen. Wer also Bock auf Handarbeit, kühle Getränke und Feuermachen in
der Pampa hat, bitte melden! :)
</p>
<p>
Nun zur To-do-Liste: zum einen gibt's viel zu <span class="accent">gärtnern</span>:
</p>
<ul>
<li>Rasen mähen</li>
<li>"Palisade" befestigen, damit sie nicht zu breit wird</li>
<li>Efeu vom Boden entfernen / Fläche neben dem Schuppen wieder nutzbar machen</li>
<li>Bäume, Büsche und Hecken auf dem Gelände verschneiden (auch überm Dach der Laube)</li>
<li>Bäume und Büsche an den Grundstücksgrenzen verschneiden (vor allem oben an der Ecke)</li>
<li>Todholz einsammeln und die Palisade erweitern / Feuerholz separat sammeln</li>
<li>Müll/Schrott sammeln</li>
<li>neuen Rasen sähen</li>
</ul>
<p>
Zum anderen gibt es Folgendes zu <span class="accent">bauen</span>:
</p>
<ul>
<li>
Bar: neue OSB-Platte drauf schrauben, nochmal lackieren<br>
Maße: 310 lang, 63 breit
</li>
<li>
Erde auf der Bühne aufschütten und neu ebnen, womöglich noch ein paar Steinplatten dazulegen
</li>
<li>
Treppe zur Laube bzw. zur "Hochebene" bauen (Erde kann für Bühne genutzt werden)
</li>
<li>
Drum-Podest erneuern: marode Stellen mit Ziegelsteinen befestigen, OSB-Platten drauf schrauben,
lackieren<br>
Maße: 240 breit, 160 lang
</li>
<li>
lose Paletten streichen
</li>
<li>
<del>Plumpsklo: Wände putzen, neuen Boden bauen, neue Sitzbank bauen, Brille besorgen</del>
<br>
Maße Sitzbank: 70 breit, 50 tief
</li>
</ul>
<p>
Hier ist der <span class="accent">Einkaufszettel</span> dafür:
</p>
<ul>
<li>Rasensamen, z.B. 5 kg für 200 m² - 30 €</li>
<li>Bar: OSB-Platte, z.B. 250x125 cm längs halbiert, 18 mm stark, 37 €</li>
<li>Drum-Podest: OSB-Verlegeplatten, z.b. 4x 205x67 cm, 25 mm stark, 80 €</li>
<li>Holzfarbe: z.B. Wetterschutzfarbe schwedenrot 2,5 l (reicht für Bar und Podest), 38 €</li>
<li>Holzlasur: z.B. 10 l Kanister, 10 €</li>
<li>
<del>Plumpsklo</del>
</li>
</ul>
<p>&rarr; ca. 200 €</p>
<p>
Und weil's so schön ist, hier noch ein paar Impressionen, Stand 30.03.:
</p>
<div class="image-list">
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-eingang-auszen.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-eingang-innen.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/bar-fern.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/bar-nah.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/buehne-bar-palisade.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/buehne.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-von-unten.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-von-vorn.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-von-oben.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-dach.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/haus-zeltplatz.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-eingang.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/haus-buesche.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/haus-feuerholz.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/unten-gewaechshaus.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/unten-wiese.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/unten-rand.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/schuppen.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-efeu.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-pissecke.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-waeldchen.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-zaun.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-portal.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/grosze-tanne.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-kirschen.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-hecke.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-oben.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-spaghetti.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-rand.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/plumpsklo.jpg' %}" alt=""></div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/plumpsklo-boden.jpg' %}" alt=""></div>
</div>
<hr>
<h2>Programm</h2>
<ul>
<li>Donnerstag 06.: ganz viel Aufbau</li>
<li>Freitag 07.: Warm-Up</li>
<li>Samstag 08.: ganz viel Live-Mucke</li>
<li>Sonntag 09.: ganz viel Abbau</li>
</ul>
<hr>
<h2>Bühne &amp; Technik</h2>
<p>zur Info für Künstler*innen und als Packliste für uns ;)</p>
<h3>Backline</h3>
<ul>
<li>
Drums:<br>
(Josi) Bassdrum, 2 Toms, Floor-Tom, Hocker, Stative für Snare, HiHat, Crash und Ride<br>
(Josi) Snare (nur für Open Stage)<br>
(Flo) Iron Cobra 600 Double Bass Pedal (darf für alles mitbenutzt werden)<br>
(Tobi) HiHat, Crash, Ride (nur für Open Stage)
</li>
<li>Bassbox: Markbass 4x10", 4 Ohm, Speakon</li>
<li>(Bass-Topteil: Markbass Little Mark Tube 800 darf mitbenutzt werden)</li>
<li>Gitarrenbox: Palmer 2x12", 8 Ohm, Klinke</li>
<li>(Gitarrenkombo: Blackstar HT-5 darf mitbenutzt werden)</li>
</ul>
<h3>Mikrofonierung</h3>
<ul>
<li>Kick: t.bone Beta 500</li>
<li>Snare: SM57</li>
<li>Toms: 3x Audix f2</li>
<li>Overheads: 2x Rode M5</li>
<li>Amps: 3x Superlux Grenzfläche</li>
<li>DI: 1x Behringer DI-Box (Mono)</li>
<li>Gesang: 1x Beta 58, 1x SM58 (Sepp), 1x Superlux</li>
<li>Stative: mal Inventur machen ...</li>
</ul>
<h3>Monitoring</h3>
<ul>
<li>3 Boxen angefragt mit der PA, noch in Klärung ...</li>
</ul>
<h3>PA</h3>
<ul>
<li>Mischpult &amp; Stagebox: Behringer X-Air 18</li>
<li>Beschallung: HK-Audio Pro, 2 18"-Subs, 2 Tops, aktiv</li>
</ul>
<h3>Beleuchtung</h3>
<ul>
<li>2x Bars mit jeweils 4 LED-Spots</li>
<li>Superfly</li>
<li>DMX-Steuerung über Laptop möglich</li>
</ul>
<h3>Kabel</h3>
<ul>
<li>24x XLR (16 Channels, 3 Monitore, 4 PA, 1 Reserve)</li>
<li>2x XLR-Male ↔ Klinke</li>
<li>13x Kaltgeräte-Stecker (Mixer, Markbass, Blackstar, 3 Monitore, 4 PA, 1 Lampe, 1 Reserve)</li>
<li>2x Kaltgeräte-Verlängerung (zwischen Lampen)</li>
<li>4x DMX (3 Lampen, 1 Reserve)</li>
<li>...</li>
</ul>
<hr>
<h2>Finanzen</h2>
<p>
Da eine offene Kommunikation irgendwie zu dem ganzen unkommerziellen DIY-Gedöhns dazugehört, findet ihr hier
ganz transparent eine Übersicht, von wo nach wo eigentlich wie viel Kohle geflossen ist.
Nur keine Klarnamen. ;)
</p>
<h3>Zusammenfassung</h3>
<table>
<tr class="odd-row">
<td>Summe aller Spenden/Zahlungen</td>
<td>{{ total_donations|euro }}</td>
</tr>
<tr class="even-row-row">
<td>Summe aller Ausgaben</td>
<td>{{ total_payments|euro }}</td>
</tr>
<tr class="odd-row">
<td>Stand</td>
<td class="accent">{{ total_balance|euro }}</td>
</tr>
</table>
<h3>Ausgaben</h3>
<table>
<thead>
<tr class="odd-row">
<td>Zweck</td>
<td>Betrag</td>
<td>Datum</td>
</tr>
</thead>
{% for payment in payments %}
<tr class="{% cycle 'even-row' 'odd-row' %}">
<td>{{ payment.purpose }}</td>
<td>{{ payment.amount|euro }}</td>
<td>{% if payment.date %}{{ payment.date|date:"d.m." }}{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</table>
<p>
Details zu den Spenden und Rückzahlungen an die Leute, die die Ausgaben geleistet haben,
lassen wir hier erstmal weg. Wer's ganz genau wissen will, kann ja fragen.
</p>
<p>
Nicht aufgelistet sind kurzfristige Dinge für die Vorbereitungsaktionen, also z.B. Suff und Sprit, den wir für
die Arbeitseinsätze gekauft und auch direkt vernichtet haben. Danke an dieser Stelle nochmal allen für die
jeweiligen Einkäufe und die unbezahlbare Arbeitskraft!
</p>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "gaehsnitz/base.html" %}
{% load static %}
{% block content %}
<h2>&#128736; Alles noch in Planung</h2>
<p>Bald gibt's mehr Infos.</p>
{% endblock %}
+22 -12
View File
@@ -1,25 +1,35 @@
{% load static %}
<!doctype html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8">
<title>Gähsnitz Open Air</title>
<link rel="stylesheet" type="text/css" href="{% static 'gaehsnitz/style.css' %}" />
<link rel="stylesheet" type="text/css" href="{% static 'gaehsnitz/style.css' %}">
</head>
<body class="{% block body_class %}{% endblock %}">
<body>
<div id="title">
<h1>Gähsnitz Open Air 2026</h1>
~ Do 11. - So 14. Juni ~
<h1>Gähsnitz Open Air 2024</h1>
~ Do 06. - So 09. Juni ~
</div>
<div id="content">{% block content %}{% endblock %}</div>
<div id="footer">
<a href="{% url 'gaehsnitz:archive-2022' %}">Archiv 2022</a>
<div id="navi">
<a href="{% url 'gaehsnitz:news' %}">News</a>
|
<a href="{% url 'gaehsnitz:archive-2024' %}">Archiv 2024</a>
<a href="{% url 'gaehsnitz:atoz' %}">A-Z</a>
|
<a href="{% url 'gaehsnitz:program' %}">Programm</a>
|
<a href="{% url 'gaehsnitz:finance' %}">Finanzen</a>
|
<a href="{% url 'gaehsnitz:for-bands' %}">für Bands</a>
</div>
<div id="content">
{% block content %}{% endblock %}
</div>
</body>
</html>
@@ -0,0 +1,62 @@
{% extends "gaehsnitz/base.html" %}
{% load money %}
{% block content %}
<h2>&#128736; Abwarten</h2>
<p>Auch hier wird noch geplant ...</p>
<h2>Details</h2>
<p>
Da eine offene Kommunikation irgendwie zu dem ganzen unkommerziellen DIY-Gedöhns dazugehört, findet ihr hier
ganz transparent eine Übersicht, von wo nach wo eigentlich wie viel Kohle geflossen ist.
Nur keine Klarnamen. ;)
</p>
<h3>Zusammenfassung</h3>
<table>
<tr class="odd-row">
<td>Summe aller Spenden/Zahlungen</td>
<td>{{ total_donations|euro }}</td>
</tr>
<tr class="even-row-row">
<td>Summe aller Ausgaben</td>
<td>{{ total_payments|euro }}</td>
</tr>
<tr class="odd-row">
<td>Stand</td>
<td class="accent">{{ total_balance|euro }}</td>
</tr>
</table>
<h3>Ausgaben</h3>
<table>
<thead>
<tr class="odd-row">
<td>Zweck</td>
<td>Betrag</td>
<td>Datum</td>
</tr>
</thead>
{% for payment in payments %}
<tr class="{% cycle 'even-row' 'odd-row' %}">
<td>{{ payment.purpose }}</td>
<td>{{ payment.amount|euro }}</td>
<td>{% if payment.date %}{{ payment.date|date:"d.m." }}{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</table>
<p>
Details zu den Spenden und Rückzahlungen an die Leute, die die Ausgaben geleistet haben,
lassen wir hier erstmal weg. Wer's ganz genau wissen will, kann ja fragen.
</p>
<p>
Nicht aufgelistet sind kurzfristige Dinge für die Vorbereitungsaktionen, also z.B. Suff und Sprit, den wir für
die Arbeitseinsätze gekauft und auch direkt vernichtet haben. Danke an dieser Stelle nochmal allen für die
jeweiligen Einkäufe und die unbezahlbare Arbeitskraft!
</p>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "gaehsnitz/base.html" %}
{% block content %}
<h2>Bühne &amp; Technik</h2>
<p>zur Info für Künstler*innen und als Packliste für uns ;)</p>
<h3>Backline</h3>
<ul>
<li>
Drums:<br>
(Josi) Bassdrum, 2 Toms, Floor-Tom, Hocker, Stative für Snare, HiHat, Crash und Ride<br>
(Josi) Snare (nur für Open Stage)<br>
(Flo) Iron Cobra 600 Double Bass Pedal (darf für alles mitbenutzt werden)<br>
(Tobi) HiHat, Crash, Ride (nur für Open Stage)
</li>
<li>Bassbox: Markbass 4x10", 4 Ohm, Speakon</li>
<li>(Bass-Topteil: Markbass Little Mark Tube 800 darf mitbenutzt werden)</li>
<li>Gitarrenbox: Palmer 2x12", 8 Ohm, Klinke</li>
<li>(Gitarrenkombo: Blackstar HT-5 darf mitbenutzt werden)</li>
</ul>
<h3>Mikrofonierung</h3>
<ul>
<li>Kick: t.bone Beta 500</li>
<li>Snare: SM57</li>
<li>Toms: 3x Audix f2</li>
<li>Overheads: 2x Rode M5</li>
<li>Amps: 3x Superlux Grenzfläche</li>
<li>DI: 1x Behringer DI-Box (Mono)</li>
<li>Gesang: 1x Beta 58, 1x SM58 (Sepp), 1x Superlux</li>
<li class="marked">Stative: mal Inventur machen ...</li>
</ul>
<h3>Monitoring</h3>
<ul>
<li class="marked">3 Boxen angefragt mit der PA, noch in Klärung ...</li>
</ul>
<h3>PA</h3>
<ul>
<li>Mischpult &amp; Stagebox: Behringer X-Air 18</li>
<li>Beschallung: HK-Audio Pro, 2 18"-Subs, 2 Tops, aktiv</li>
</ul>
<h3>Beleuchtung</h3>
<ul>
<li>2x Bars mit jeweils 4 LED-Spots</li>
<li>Superfly</li>
<li>DMX-Steuerung über Laptop möglich</li>
</ul>
<h3>Kabel</h3>
<ul>
<li>24x XLR (16 Channels, 3 Monitore, 4 PA, 1 Reserve)</li>
<li>2x XLR-Male ↔ Klinke</li>
<li>13x Kaltgeräte-Stecker (Mixer, Markbass, Blackstar, 3 Monitore, 4 PA, 1 Lampe, 1 Reserve)</li>
<li>2x Kaltgeräte-Verlängerung (zwischen Lampen)</li>
<li>4x DMX (3 Lampen, 1 Reserve)</li>
<li>...</li>
</ul>
{% endblock %}
+149 -130
View File
@@ -1,149 +1,168 @@
{% extends "gaehsnitz/base.html" %}
{% load static %} {% load money %}
{% load static %}
{% block content %}
<a href="{% static 'gaehsnitz/poster-2026.jpg' %}" target="_blank" class="main-poster-link">
<img src="{% static 'gaehsnitz/poster-2026.jpg' %}" alt="Plakat 2026" class="main-poster">
<h2>06.05. - Hochoffizielle Einladung</h2>
<p>
Der Plan für Gähsnitz dieses Jahr steht und zur Motivation gibt's diesmal auch 'n digitales Plakat. 🍾
</p>
<a href="{% static 'gaehsnitz/plakat-2024.jpg' %}">
<img id="flyer" src="{% static 'gaehsnitz/plakat-2024-small.jpg' %}" alt="Plakat">
</a>
<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>🤫 Es ist eine Privatparty - bitte keine öffentliche Werbung machen! Bringt aber gern Freund*innen mit.</p>
<p>
⛺ Zelten könnt ihr im Garten selbst oder auf der Wiese dahinter. Parken vor dem Grundstück oder ebenfalls hinten
auf der Wiese.
Fühlt euch alle eingeladen, am 6., 7., spätestens aber am 8. Juni bei uns im Garten vorbeizuschneien. Es gibt
bunt gemischte Live-Musik, Getränke für'n schmalen Taler, echte Landluft und was sonst noch alles auf dem Bild
zu finden ist.
</p>
<p>
🚉 Der nächste Bahnhof ist in Gößnitz, mit dem Auto 13km / 17min entfernt, mit dem Fahrrad 12km / 40min. Die S5 bzw.
S5X von Leipzig Hbf fährt jede halbe Stunde, um :28 und :58. Fragt uns ansonsten gern nach Mitfahrgelegenheiten!
Mehr Details gibt's nochmal 1-2 Wochen vorher - bis dahin einfach den Termin freihalten, Urlaub nehmen oder
kündigen, und am besten auch schonmal üben, wie man ganz schnell Pavillons aufbaut und Wurfzelte wieder in ihre
Verpackung kriegt.
</p>
<p>🔊 Achtet auf Anwohner*innen - Live-Mucke gibt's bis 22:00, danach Party auf 70% Lautstärke.</p>
<p>🔥 Seid vorsichtig mit offenem Feuer und werft keinen Müll (auch Kronkorken) in den Garten.</p>
<p>
🍲 Es gibt eine vegane Hauptmahlzeit (ebenfalls für'n schmalen Taler) am Freitag und Samstag. Versorgt euch darüber
hinaus bitte selbst. Es gibt Sandwich-Maker, also könnt ihr gern Toast und Zutaten dafür mitbringen.
Bringt gern noch mehr liebe Leute mit, aber bitte nich bei MySpace posten oder Plakate aufhängen - ist immer
noch 'ne Privatveranstaltung. ;)
</p>
<p>🚽 Es gibt Dixis, Desinfektionsmittel und eine Wasserstelle mit Gartenschlauch (Trinkwasser) - keine Dusche.</p>
<hr />
<hr>
<h2>Für Bands</h2>
<h3>Konditionen</h3>
<h2>04.04. - Der Winterschlaf ist vorbei</h2>
<p>
Der Termin für unsere diesjährige Gartensause steht ja schon seit letztem Jahr fest:
<span class="accent">06. - 09. Juni</span>. Sogar die Dixis sind schon vorbestellt! Den größten Fokus (und die
meiste Live-Mucke) wollen wir dieses Mal auf Samstag (08.06.) legen, aber mehr dazu später.
</p>
<p>
Über Ostern waren haben wir uns mal das Gelände angeschaut und uns gefreut, dass sich in anderthalb Jahren gar
nicht mal so viel verändert hat - sogar das Drum-Podest ist noch nutzbar. Trotzdem gibt's natürlich alle Hände
voll zu tun, um den Garten wieder festivaltauglich zu machen und zu konservieren.
</p>
<p>
Wir wollen über <span class="accent">Himmelfahrt</span> (09. - 12.05.) nochmal hinfahren und könnten dafür auch
so viele helfende Hände wie möglich gebrauchen. Wer also Bock auf Handarbeit, kühle Getränke und Feuermachen in
der Pampa hat, bitte melden! :)
</p>
<p>
Nun zur To-do-Liste: zum einen gibt's viel zu <span class="accent">gärtnern</span>:
</p>
<ul>
<li>Mindestens Spritkohle, darüber hinaus Gage, mit der ihr euch wohlfühlt ;)</li>
<li>Getränke und eine warme Mahlzeit am Spieltag kostenlos</li>
<li>Keine Schlafplätze und keine Dusche nur Camping (Wasserstelle gibt's)</li>
<li>Wenig Bands / kein Zeitdruck, aber 22:00 sollte die Live-Mucke langsam fertig sein</li>
<li>Rasen mähen</li>
<li>"Palisade" befestigen, damit sie nicht zu breit wird</li>
<li>Efeu vom Boden entfernen / Fläche neben dem Schuppen wieder nutzbar machen</li>
<li>Bäume, Büsche und Hecken auf dem Gelände verschneiden (auch überm Dach der Laube)</li>
<li>Bäume und Büsche an den Grundstücksgrenzen verschneiden (vor allem oben an der Ecke)</li>
<li>Todholz einsammeln und die Palisade erweitern / Feuerholz separat sammeln</li>
<li>Müll/Schrott sammeln</li>
<li>neuen Rasen sähen</li>
</ul>
<h3>Ablauf</h3>
<p>vorläufig kann diskutiert, verschoben und vertauscht werden</p>
<p>Donnerstag: Aufbau, Grundeinstellung, Soundcheck, evtl. Jam-Session &#x1F60E;</p>
<p>Freitag:</p>
<p>
Zum anderen gibt es Folgendes zu <span class="accent">bauen</span>:
</p>
<ul>
<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>20:30 Umbau + Line-Check Mörtel</li>
<li>21:00 Mörtel</li>
<li>
Bar: neue OSB-Platte drauf schrauben, nochmal lackieren<br>
Maße: 310 lang, 63 breit
</li>
<li>
Erde auf der Bühne aufschütten und neu ebnen, womöglich noch ein paar Steinplatten dazulegen
</li>
<li>
Treppe zur Laube bzw. zur "Hochebene" bauen (Erde kann für Bühne genutzt werden)
</li>
<li>
Drum-Podest erneuern: marode Stellen mit Ziegelsteinen befestigen, OSB-Platten drauf schrauben,
lackieren<br>
Maße: 240 breit, 160 lang
</li>
<li>
lose Paletten streichen
</li>
<li>
<del>Plumpsklo: Wände putzen, neuen Boden bauen, neue Sitzbank bauen, Brille besorgen</del>
<br>
Maße Sitzbank: 70 breit, 50 tief
</li>
</ul>
<p>Samstag:</p>
<p>
Hier ist der <span class="accent">Einkaufszettel</span> dafür:
</p>
<ul>
<li>16:30 Umbau + Line-Check Kotpiloten</li>
<li>17:00 Kotpiloten</li>
<li>18:00 Essenspause</li>
<li>18:45 Umbau + Line-Check Knast</li>
<li>19:15 Knast</li>
<li>20:15 Umbau + Line-Check Quast</li>
<li>20:45 Quast</li>
<li>Rasensamen, z.B. 5 kg für 200 m² - 30 €</li>
<li>Bar: OSB-Platte, z.B. 250x125 cm längs halbiert, 18 mm stark, 37 €</li>
<li>Drum-Podest: OSB-Verlegeplatten, z.b. 4x 205x67 cm, 25 mm stark, 80 €</li>
<li>Holzfarbe: z.B. Wetterschutzfarbe schwedenrot 2,5 l (reicht für Bar und Podest), 38 €</li>
<li>Holzlasur: z.B. 10 l Kanister, 10 €</li>
<li>
<del>Plumpsklo</del>
</li>
</ul>
<h3>Backline für alle</h3>
<p>Drums von <span class="marked">Josi</span> bitte mit &#x2764; behandeln</p>
<ul>
<li>Bassdrum</li>
<li>1 Hänge- + 2 Standtoms</li>
<li>Stative für Snare, Hi-Hat, 1x Crash und 1x Ride</li>
<li>Drum-Hocker</li>
<li>Bassbox: Markbass 4x10", 4 Ohm, Speakon</li>
<li>Gitarrenbox: 2x12", 8 Ohm, Klinke <span class="marked">(Steve)</span></li>
</ul>
<h3>Was mitgenutzt werden kann ...</h3>
<ul>
<li>Bass-Topteil: Markbass Little Mark Tube 800</li>
<li>Git.-Topteil: EVH 5150</li>
<li>Git.-Kombo: Blackstar HT-5</li>
<li>Fußmaschine: Iron Cobra 600 Double</li>
</ul>
<h3>Bitte mitbringen ...</h3>
<ul>
<li>Snare</li>
<li>Becken</li>
<li>Fußmaschine (wenn ihr feste zutretet)</li>
<li>Mehr Beckenständer, wenn die o.g. nicht reichen</li>
<li>Topteil / Kombos (wenn ihr lieber eigene nutzt)</li>
<li>Instrumente, Pedale, Kleinkram ...</li>
<li>Gesangsmikros (wenn ihr gern eigene nutzt)</li>
<li>Spezialmikros (wenn die u.g. nicht reichen)</li>
</ul>
<h3>Nur zum Jammen / was wir ungern verleihen ...</h3>
<ul>
<li>Snare <span class="marked">(Tobi)</span></li>
<li>Becken: Crash, Ride, HiHat <span class="marked">(Tobi)</span></li>
</ul>
<h3>Mikrofonierung</h3>
<ul>
<li>Kick: t.bone Beta 500 | Sennheiser E902</li>
<li>Snare: Shure SM57 | SE Electronics V7 X</li>
<li>Toms: 3x Audix f2</li>
<li>Overheads: 2x Rode M5</li>
<li>Amps: 1x Sennheiser E609 | 2x Superlux PRA 628</li>
<li>Gesang: 1x Shure Beta 58 | 2x Shure SM58 <span class="marked">(geplant)</span></li>
<li>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>
</ul>
<h3>Monitoring</h3>
<ul>
<li>2x Alto PA <span class="marked">(PR)</span></li>
<li class="marked">1x JBL alte Proberaum-PA ausprobieren</li>
</ul>
<h3>PA</h3>
<ul>
<li>Mischpult &amp; Stagebox: Behringer X-Air 18</li>
<li class="marked">Tablet: Enni fragen</li>
<li>Beschallung: HK-Audio Pro, 2 Subs, 2 Tops, aktiv</li>
</ul>
<h3>Beleuchtung</h3>
<ul>
<li class="marked">alles von Franz</li>
</ul>
<h3>Kabel</h3>
<ul>
<li class="marked">Liste vom letzten mal nachzählen</li>
<li>24x XLR (16 Channels, 3 Monitore, 4 PA, 1 Reserve)</li>
<li>2x XLR-Male ↔ Klinke</li>
<li>13x Kaltgeräte-Stecker (Mixer, Markbass, Blackstar, 3 Monitore, 4 PA, 1 Lampe, 1 Reserve)</li>
<li>2x Kaltgeräte-Verlängerung (zwischen Lampen)</li>
<li>4x DMX (3 Lampen, 1 Reserve)</li>
<li>...</li>
</ul>
<hr />
<h2>Finanzen</h2>
<p>... folgen in Kürze.</p>
<p>&rarr; ca. 200 €</p>
<p>
Und weil's so schön ist, hier noch ein paar Impressionen, Stand 30.03.:
</p>
<div class="image-list">
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-eingang-auszen.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-eingang-innen.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/bar-fern.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/bar-nah.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/buehne-bar-palisade.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/buehne.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-von-unten.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-von-vorn.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-von-oben.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/laube-dach.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/haus-zeltplatz.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-eingang.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/haus-buesche.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/haus-feuerholz.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/unten-gewaechshaus.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/unten-wiese.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/unten-rand.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/schuppen.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-efeu.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-pissecke.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-waeldchen.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-zaun.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/rand-portal.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/grosze-tanne.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-kirschen.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-hecke.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/strasze-oben.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-spaghetti.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/feld-rand.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/plumpsklo.jpg' %}" alt="">
</div>
<div class="image-wrapper"><img src="{% static 'gaehsnitz/photos-2024-03-30/plumpsklo-boden.jpg' %}" alt="">
</div>
</div>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "gaehsnitz/base.html" %}
{% load static %}
{% block content %}
<h2>&#128736; Donnerstag 06.</h2>
<p>ganz viel Aufbau</p>
<h2>&#128736; Freitag 07.</h2>
<p>Warm-Up</p>
<h2>&#128736; Samstag 08.</h2>
<p>ganz viel Live-Mucke</p>
<h2>&#128736; Sonntag 09.</h2>
<p>ganz viel Abbau</p>
{% endblock %}
-43
View File
@@ -1,43 +0,0 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<title>Suff - Gähsnitz Open Air</title>
<link rel="icon" type="image/svg+xml" href="{% static 'suff/favicon.svg' %}" />
<link rel="stylesheet" href="{% static 'suff/style.css' %}" />
</head>
<body>
<main>
<p class="site-name">Gähsnitz Open Air</p>
<h1>Suff</h1>
{% block content %}{% endblock %}
</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>
</html>
-19
View File
@@ -1,19 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>PIN ändern</h2>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:change_pin' %}">
{% csrf_token %}
<label>
Neue PIN:
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}" maxlength="3"
minlength="3" required autofocus autocomplete="off" />
</label>
<button type="submit">PIN speichern</button>
</form>
<p><a href="{% url 'suff:me' %}">Abbrechen</a></p>
{% endblock %}
-10
View File
@@ -1,10 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
{% if phase == "before" %}
<p>Hier könnt ihr während des Festivals eure Getränke selbst buchen und den Überblick über eure Rechnung behalten — die digitale Strichliste.</p>
<p>Das Tool startet am <strong>{{ booking_start|date:"d.m.Y" }}</strong> — schaut dann nochmal rein!</p>
{% else %}
<p>Das Festival ist vorbei. Das Tool ist jetzt deaktiviert.</p>
{% endif %}
{% endblock %}
@@ -1,19 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Buchung löschen?</h2>
<p>
Willst du wirklich
<strong>{{ consumption.drink.name }}</strong>
({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %})
von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %}
löschen?
</p>
<form method="post" action="{% url 'suff:delete_consumption' consumption.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:me' %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
@@ -1,21 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Zahlung löschen?</h2>
<p>
Willst du wirklich diese Zahlung löschen?
</p>
<section class="total-box">
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} {{ payment.note }}{% endif %}</span>
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
</section>
<form method="post" action="{% url 'suff:delete_payment' payment.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:pay' %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
-257
View File
@@ -1,257 +0,0 @@
{% extends "suff/base.html" %}
{% load money %}
{% block content %}
<h2>Dashboard {{ year }}</h2>
<section>
<h3>Finanzen</h3>
<div class="progress-wrap">
<div class="progress-bar" style="width: {{ finance_pct_capped }}%;"></div>
<span class="progress-label">{{ finance_pct }}%</span>
</div>
<ul class="history">
<li class="fin-row">
<span class="hist-what"><b>Ausgaben gesamt</b></span>
<span class="hist-price"><b>{{ total_costs|euro }}</b></span>
</li>
<li class="fin-row">
<span class="hist-what"><b>Einnahmen gesamt</b></span>
<span class="hist-price"><b>{{ income_total|euro }}</b></span>
</li>
<li class="fin-row">
<span class="hist-what"><b>Bilanz</b></span>
<span class="hist-price"><b>{{ finance_balance|euro }}</b></span>
</li>
</ul>
<ul class="history history-sub">
<li class="fin-row">
<span class="hist-what">Spenden (Donations)</span>
<span class="hist-price">{{ total_donations|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Cashless (User-Tab)</span>
<span class="hist-price">{{ user_payments_total|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Kasse (bar, nach Vorschuss)</span>
<span class="hist-price">{{ cash_net|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Kassen-Vorschuss</span>
<span class="hist-price">{{ cash_prefill|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Einkaufspreis Getränke</span>
<span class="hist-price">{{ purchase_cost|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Verkaufserlös Getränke</span>
<span class="hist-price">{{ sales_revenue|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Gratis-Getränke (EK-Wert)</span>
<span class="hist-price">{{ free_drinks_value|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Unverkauft (EK-Wert)</span>
<span class="hist-price">{{ unsold_purchase_value|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Unverkauft (potenzieller VK)</span>
<span class="hist-price">{{ unsold_sale_value|euro }}</span>
</li>
</ul>
</section>
<section>
<h3>Getränke</h3>
<ul class="history">
{% for d in drink_rows %}
<li class="dash-row">
<span class="hist-what"><b>{{ d.name }}</b></span>
<span class="hist-when">{{ d.sold }}/{{ d.total }} verkauft</span>
<span class="hist-price">{{ d.balance|euro }}</span>
</li>
{% endfor %}
</ul>
</section>
<section>
<h3>Offene Beträge</h3>
{% if user_rows %}
<ul class="history">
{% for u in user_rows %}
<li>
<span class="hist-when">{{ u.username }}</span>
<span class="hist-what">{{ u.consumed|euro }} {{ u.paid|euro }}</span>
<span class="hist-price">{{ u.open|euro }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted-left">Niemand hat etwas konsumiert.</p>
{% endif %}
</section>
<section>
<h3>Fun Facts: Trinker</h3>
<ul class="history">
{% if top_spender %}
<li>
<span class="hist-when">Top-Zecher</span>
<span class="hist-what">{{ top_spender.username }}</span>
<span class="hist-price">{{ top_spender.total|euro }}</span>
</li>
{% endif %}
{% if variety_champ %}
<li>
<span class="hist-when">Vielfalt-Champion</span>
<span class="hist-what">{{ variety_champ.user__username }}</span>
<span class="hist-price">{{ variety_champ.distinct }} Sorten</span>
</li>
{% endif %}
{% for f in top_per_day %}
<li>
<span class="hist-when">Top {{ f.label }}</span>
<span class="hist-what">{{ f.username }}</span>
<span class="hist-price">{{ f.amount }} Flaschen</span>
</li>
{% endfor %}
</ul>
</section>
<section>
<h3>Fun Facts: Getränke</h3>
<ul class="history">
{% if top_drink %}
<li>
<span class="hist-when">Top-Getränk</span>
<span class="hist-what">{{ top_drink.name }}</span>
<span class="hist-price">{{ top_drink.amount }}x</span>
</li>
{% endif %}
<li>
<span class="hist-when">Bier-Anteil</span>
<span class="hist-what">am Konsum</span>
<span class="hist-price">{{ beer_share }}%</span>
</li>
{% if alcfree_top %}
<li>
<span class="hist-when">{{ alcfree_top.label }}</span>
<span class="hist-what">{{ alcfree_top.username }}</span>
<span class="hist-price">{{ alcfree_top.amount }} Flaschen</span>
</li>
{% endif %}
{% if radler_top %}
<li>
<span class="hist-when">{{ radler_top.label }}</span>
<span class="hist-what">{{ radler_top.username }}</span>
<span class="hist-price">{{ radler_top.amount }} Flaschen</span>
</li>
{% endif %}
{% if water_top %}
<li>
<span class="hist-when">{{ water_top.label }}</span>
<span class="hist-what">{{ water_top.username }}</span>
<span class="hist-price">{{ water_top.amount }} Flaschen</span>
</li>
{% endif %}
</ul>
</section>
<section>
<h3>Fun Facts: Zeit</h3>
<ul class="history">
{% if first_cons %}
<li>
<span class="hist-when">Erste Buchung</span>
<span class="hist-what">{{ first_cons.user.username }} {{ first_cons.drink.name }}</span>
<span class="hist-price">{{ first_cons.created_at|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if last_cons %}
<li>
<span class="hist-when">Letzter Schluck</span>
<span class="hist-what">{{ last_cons.user.username }} {{ last_cons.drink.name }}</span>
<span class="hist-price">{{ last_cons.created_at|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if earliest_per_user %}
<li>
<span class="hist-when">Frühaufsteher</span>
<span class="hist-what">{{ earliest_per_user.user__username }}</span>
<span class="hist-price">{{ earliest_per_user.t|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if latest_per_user %}
<li>
<span class="hist-when">Nachtschwärmer</span>
<span class="hist-what">{{ latest_per_user.user__username }}</span>
<span class="hist-price">{{ latest_per_user.t|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if golden_hour %}
<li>
<span class="hist-when">Goldene Stunde</span>
<span class="hist-what">{{ golden_hour.label }}</span>
<span class="hist-price">{{ golden_hour.amount }} Flaschen</span>
</li>
{% endif %}
{% if busiest_day %}
<li>
<span class="hist-when">Härtester Tag</span>
<span class="hist-what">{{ busiest_day.label }}</span>
<span class="hist-price">{{ busiest_day.amount }} Flaschen</span>
</li>
{% endif %}
</ul>
</section>
<section>
<h3>Fun Facts: Geld</h3>
<ul class="history">
{% if biggest_tip %}
<li>
<span class="hist-when">Bigspender-Trinkgeld</span>
<span class="hist-what">{{ biggest_tip.username }}</span>
<span class="hist-price">{{ biggest_tip.tip|euro }}</span>
</li>
{% endif %}
<li>
<span class="hist-when">Eintrittsspenden gesamt</span>
<span class="hist-what">aus Überzahlungen</span>
<span class="hist-price">{{ total_entry|euro }}</span>
</li>
{% for method, sum in method_split.items %}
<li>
<span class="hist-when">Zahlungen {{ method }}</span>
<span class="hist-what"></span>
<span class="hist-price">{{ sum|euro }}</span>
</li>
{% endfor %}
</ul>
</section>
<section>
<h3>Fun Facts: Gratis</h3>
<ul class="history">
<li>
<span class="hist-when">Aufmerksamkeit des Hauses</span>
<span class="hist-what">Gratis-Flaschen</span>
<span class="hist-price">{{ free_total_count }}x</span>
</li>
{% if top_free_recipient %}
<li>
<span class="hist-when">Glücklicher Empfänger</span>
<span class="hist-what">{{ top_free_recipient.user__username }}</span>
<span class="hist-price">{{ top_free_recipient.amount }} gratis</span>
</li>
{% endif %}
</ul>
</section>
<div class="logout-form">
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
-138
View File
@@ -1,138 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Hallo <span class="self-username">{{ tab_user.username }}</span></h2>
<p class="intro">
Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
alles zusammen mit deiner <strong>Eintrittsspende</strong> bezahlen
bar oder per PayPal.
</p>
{% if booked_drink %}
<div class="toast" role="status">
Gebucht: +1 {{ booked_drink.name }}
</div>
{% endif %}
{% if paid_toast %}
<div class="toast" role="status">
Zahlung gespeichert. Danke!
</div>
{% endif %}
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
{% if paid and open_balance <= 0 %}
<span class="total-settled">Bezahlt ✓</span>
<div class="total-breakdown">
<span>Drinks {{ total|floatformat:2 }} €</span>
<span>·</span>
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
<span>·</span>
{% if open_balance < 0 %}
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
{% else %}
<span>Genau bezahlt</span>
{% endif %}
</div>
{% else %}
<span class="total-label">Deine Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
<span class="total-label">Offen</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
{% endif %}
{% endif %}
</section>
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Für Drinks und Eintritt spenden</a></p>
{% if phase == "booking" %}
<section>
<h3>Neues Getränk buchen</h3>
<form method="post" action="{% url 'suff:book' %}">
{% 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>
<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-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:delete_consumption' 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>
{% 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 %}
<button type="submit" class="btn-secondary">Logout</button>
</form>
</div>
{% endblock %}
-19
View File
@@ -1,19 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Wer bist du?</h2>
<p>
Gib deinen Namen ein. Sonderzeichen, Leerzeichen und Großbuchstaben werden
entfernt (z.B. wird aus "Flo Hä!" → "flo-ha"). Merk dir den Namen so,
wie er hier nach dem Anlegen angezeigt wird.
</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:name' %}">
{% csrf_token %}
<label>
Name
<input type="text" name="name" autofocus required autocomplete="off" />
</label>
<button type="submit">Weiter</button>
</form>
{% endblock %}
-16
View File
@@ -1,16 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>PIN benötigt</h2>
<p>
Für den Namen <b>{{ username }}</b> ist keine PIN gesetzt, aber
der Account hat schon Buchungen oder Zahlungen.
</p>
<p>
Aus Sicherheit kannst du diesen Account nicht selbst übernehmen.
Bitte jemanden an der Bar, dir eine neue PIN zu setzen.
</p>
<div class="link-row">
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
</div>
{% endblock %}
-15
View File
@@ -1,15 +0,0 @@
{% 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
@@ -1,118 +0,0 @@
{% extends "suff/base.html" %}
{% load l10n %}
{% block content %}
<h2>Spenden</h2>
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
{% if paid and open_balance <= 0 %}
<span class="total-settled">Bezahlt ✓</span>
<div class="total-breakdown">
<span>Drinks {{ total|floatformat:2 }} €</span>
<span>·</span>
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
<span>·</span>
{% if open_balance < 0 %}
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
{% else %}
<span>Genau bezahlt</span>
{% endif %}
</div>
{% else %}
<span class="total-label">Deine Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
{% endif %}
<span class="total-label">Offen (Drinks)</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
{% endif %}
</section>
<p class="intro">
Dein Beitrag deckt die <strong>Drinks</strong> und deine
<strong>Eintrittsspende</strong>. Die Vorschläge unten runden deinen
offenen Drink-Betrag auf die nächsten 5&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 %}
-34
View File
@@ -1,34 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
{% if mode == "create" %}
<h2>Neuer Account: {{ username }}</h2>
<p>Merk dir diesen Namen: <b>{{ username }}</b>. Du brauchst ihn beim nächsten Login.</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 %}
<h2>Hallo {{ username }}</h2>
<p>Gib deine 3-stellige PIN ein.</p>
{% endif %}
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:pin' %}">
{% csrf_token %}
<label>
PIN:
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}" maxlength="3"
minlength="3" required autofocus autocomplete="off" />
</label>
<button type="submit">
{% if mode == "create" %}Account anlegen{% elif mode == "claim" %}Account übernehmen{% else %}Login{% endif %}
</button>
</form>
<p>
Nicht du / keine PIN / vergessen?
<a href="{% url 'suff:name' %}">Zurück und neuen Namen wählen</a>
</p>
{% endblock %}
@@ -1,24 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Buchung von <span class="staff-target">{{ tab_user.username }}</span> löschen?</h2>
<p>
Willst du wirklich
<strong>{{ consumption.drink.name }}</strong>
({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %})
von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %}
löschen?
</p>
{% if tab_user.username == "anonym" %}
<p class="muted">Die zugehörige Bar-Zahlung wird ebenfalls entfernt.</p>
{% endif %}
<form method="post" action="{% url 'suff:staff_delete_consumption' tab_user.username consumption.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:staff_user' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
@@ -1,18 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Zahlung löschen für <span class="staff-target">{{ tab_user.username }}</span>?</h2>
<section class="total-box">
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} {{ payment.note }}{% endif %}</span>
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
</section>
<form method="post" action="{% url 'suff:staff_delete_payment' tab_user.username payment.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
-45
View File
@@ -1,45 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Benutzer wählen</h2>
<p>Buchung oder Zahlung im Auftrag eines Benutzers eintragen.</p>
<div class="link-row">
<a href="{% url 'suff:staff_register' %}" class="link-btn">Neuen Benutzer anlegen</a>
</div>
{% if anonymous %}
<div class="link-row">
<a href="{% url 'suff:staff_user' anonymous.username %}" class="link-btn">
Anonymer Gast (Bar)
</a>
</div>
{% endif %}
<section>
<h3>Registrierte Benutzer</h3>
{% if users %}
<ul class="history">
{% for u in users %}
<li>
<span class="hist-what">
{% if u.username == request.user.username %}
<span class="user-self">{{ u.username }} <span class="muted-inline">(das bist du)</span></span>
{% else %}
<a href="{% url 'suff:staff_user' u.username %}">{{ u.username }}</a>
{% endif %}
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Keine Benutzer.</p>
{% endif %}
</section>
<div class="logout-form">
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
-81
View File
@@ -1,81 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Zahlung für <span class="staff-target">{{ tab_user.username }}</span></h2>
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
{% if paid and open_balance <= 0 %}
<span class="total-settled">Bezahlt ✓</span>
<div class="total-breakdown">
<span>Drinks {{ total|floatformat:2 }} €</span>
<span>·</span>
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
<span>·</span>
{% if open_balance < 0 %}
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
{% else %}
<span>Genau bezahlt</span>
{% endif %}
</div>
{% else %}
<span class="total-label">Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
{% endif %}
<span class="total-label">Offen</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
{% endif %}
</section>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="{% url 'suff:staff_pay' tab_user.username %}" class="pay-form">
{% csrf_token %}
<label>
Betrag (€)
<input type="number" name="amount" step="0.01" min="0.01"
value="{% if open_balance > 0 %}{{ open_balance|floatformat:2 }}{% else %}0.00{% endif %}" required />
</label>
<label>
Methode
<select name="method" required>
<option value="">— wählen —</option>
{% for value, label in payment_methods %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
Notiz (optional)
<input type="text" name="note" maxlength="64" />
</label>
<button type="submit" class="btn-primary">Zahlung eintragen</button>
</form>
{% if user_payments %}
<section>
<h3>Bisherige Zahlungen</h3>
<ul class="history">
{% for p in user_payments %}
<li>
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} {{ p.note }}{% endif %}</span>
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
{% if phase == "booking" %}
<a href="{% url 'suff:staff_delete_payment' tab_user.username p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
<div class="logout-form">
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
@@ -1,24 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>PIN setzen für <span class="staff-target">{{ tab_user.username }}</span></h2>
<p>Neue 3-stellige PIN eingeben. Eine bestehende PIN wird überschrieben.</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:staff_pin_reset' tab_user.username %}">
{% csrf_token %}
<label>
Neue PIN
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
maxlength="3" minlength="3" required autofocus autocomplete="off" />
</label>
<button type="submit" class="btn-primary">PIN speichern</button>
</form>
<div class="logout-form">
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
@@ -1,29 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Neuen Benutzer anlegen</h2>
<p>Name eingeben. PIN ist optional — kann später bei Bedarf gesetzt werden.</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:staff_register' %}">
{% csrf_token %}
<label>
Name
<input type="text" name="name" maxlength="150" required autofocus
value="{{ prefill_name }}" />
</label>
<label>
PIN (optional, 3 Ziffern)
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
maxlength="3" autocomplete="off" value="{{ prefill_pin }}" />
</label>
<button type="submit" class="btn-primary">Anlegen</button>
</form>
<div class="logout-form">
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
-132
View File
@@ -1,132 +0,0 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Buchen für <span class="staff-target">{{ tab_user.username }}</span></h2>
{% if booked_drink %}
<div class="toast" role="status">
Gebucht: +1 {{ booked_drink.name }}{% if booked_free %} (gratis){% elif is_anonymous_target %} (bar bezahlt){% endif %}
</div>
{% endif %}
{% if paid_toast %}
<div class="toast" role="status">
Zahlung gespeichert.
</div>
{% endif %}
{% if is_anonymous_target %}
<p class="muted">Anonyme Buchungen werden automatisch als bar bezahlt eingetragen.</p>
{% else %}
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
{% if paid and open_balance <= 0 %}
<span class="total-settled">Bezahlt ✓</span>
<div class="total-breakdown">
<span>Drinks {{ total|floatformat:2 }} €</span>
<span>·</span>
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
<span>·</span>
{% if open_balance < 0 %}
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
{% else %}
<span>Genau bezahlt</span>
{% endif %}
</div>
{% else %}
<span class="total-label">Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
<span class="total-label">Offen</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
{% endif %}
{% endif %}
</section>
<p><a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-primary">Zahlung eintragen</a></p>
{% endif %}
{% if phase == "booking" %}
<section>
<h3>Getränk buchen</h3>
<form method="post" action="{% url 'suff:staff_book' tab_user.username %}">
{% csrf_token %}
<label class="for-free-toggle">
<input type="checkbox" name="booking_mode" value="for_free" />
<span>Gratis (z.B. Artists am Spieltag)</span>
</label>
{% if not is_anonymous_target %}
<label class="for-free-toggle">
<input type="checkbox" name="booking_mode" value="cash_paid" />
<span>Direkt bar bezahlt</span>
</label>
{% endif %}
<script>
document.querySelectorAll('input[name="booking_mode"]').forEach(function(cb) {
cb.addEventListener('change', function() {
if (this.checked) {
document.querySelectorAll('input[name="booking_mode"]').forEach(function(other) {
if (other !== cb) other.checked = false;
});
}
});
});
</script>
{% regroup drinks by alcohol_label as drink_groups %}
{% for group in drink_groups %}
<h4 class="drink-group-heading">{{ group.grouper }}</h4>
<div class="drink-grid">
{% for drink in group.list %}
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
<span class="drink-plus">+1</span>
<span class="drink-name">{{ drink.name }}</span>
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
</button>
{% endfor %}
</div>
{% endfor %}
</form>
</section>
{% endif %}
<section>
<h3>Bisher gebucht</h3>
{% if consumption_list %}
{% regroup consumption_list by get_day_display as day_groups %}
{% for group in day_groups %}
<div class="day-group">
<h4 class="day-heading">{{ group.grouper }}</h4>
<ul class="history">
{% for c in group.list %}
<li>
<span class="hist-when">{% if c.created_at %}{{ c.created_at|date:"H:i" }}{% else %}—{% endif %}</span>
<span class="hist-what">{{ c.drink.name }}</span>
<span class="hist-price">
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
</span>
{% if phase == "booking" %}
<a href="{% url 'suff:staff_delete_consumption' tab_user.username c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p class="empty-emoji">🍺</p>
<p>Noch nichts gebucht.</p>
</div>
{% endif %}
</section>
<div class="link-row">
{% if not is_anonymous_target %}
<a href="{% url 'suff:staff_pin_reset' tab_user.username %}" class="link-btn link-btn-secondary">PIN setzen / zurücksetzen</a>
{% endif %}
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Anderen Benutzer wählen</a>
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück zu mir</a>
</div>
{% endblock %}
+5 -3
View File
@@ -1,9 +1,11 @@
from django.urls import path
from gaehsnitz.views import NewsView, Archive2022View, Archive2024View
from gaehsnitz.views import NewsView, AToZView, ProgramView, FinanceView, ForBandsView
urlpatterns = [
path("", NewsView.as_view(), name="news"),
path("archive/2022", Archive2022View.as_view(), name="archive-2022"),
path("archive/2024", Archive2024View.as_view(), name="archive-2024"),
path("atoz", AToZView.as_view(), name="atoz"),
path("program", ProgramView.as_view(), name="program"),
path("finance", FinanceView.as_view(), name="finance"),
path("for-bands", ForBandsView.as_view(), name="for-bands"),
]
+18 -43
View File
@@ -14,17 +14,27 @@ class GaehsnitzTemplateView(TemplateView):
context = super().get_context_data(**kwargs)
delta_til_start = festival_start_date - date.today()
days_til_start = max(delta_til_start.days, 0)
context.update(
{
context.update({
"days_til_festival_start": days_til_start,
}
)
})
return context
class NewsView(GaehsnitzTemplateView):
template_name = "gaehsnitz/news.html"
class AToZView(GaehsnitzTemplateView):
template_name = "gaehsnitz/atoz.html"
class ProgramView(GaehsnitzTemplateView):
template_name = "gaehsnitz/program.html"
class FinanceView(GaehsnitzTemplateView):
template_name = "gaehsnitz/finance.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -45,49 +55,14 @@ class NewsView(GaehsnitzTemplateView):
displayed_payments.append(Payment(purpose="Bands", amount=band_sum))
context.update(
{
context.update({
"total_donations": total_donations,
"total_payments": total_payments,
"total_balance": total_balance,
"payments": displayed_payments,
}
)
})
return context
class Archive2022View(GaehsnitzTemplateView):
template_name = "gaehsnitz/archive-2022.html"
class Archive2024View(GaehsnitzTemplateView):
template_name = "gaehsnitz/archive-2024.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
donations = Donation.objects.filter(date__year=2024)
payments = Payment.objects.filter(date__year=2024)
total_donations = donations.aggregate(sum=Sum("amount"))["sum"] or 0
total_payments = payments.aggregate(sum=Sum("amount"))["sum"] or 0
total_balance = total_donations - total_payments
band_sum, displayed_payments = 0, []
for pay in payments.order_by("date"):
if pay.purpose.startswith("Band"):
band_sum += pay.amount
else:
displayed_payments.append(pay)
displayed_payments.append(Payment(purpose="Bands", amount=band_sum))
context.update(
{
"total_donations": total_donations,
"total_payments": total_payments,
"total_balance": total_balance,
"payments": displayed_payments,
}
)
return context
class ForBandsView(GaehsnitzTemplateView):
template_name = "gaehsnitz/for-bands.html"
+1 -9
View File
@@ -94,18 +94,10 @@ TEMPLATES = [
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
TIME_ZONE = "Europe/Berlin"
LANGUAGE_CODE = "de"
USE_I18N = True
USE_I18N = False
USE_L10N = False
USE_TZ = True
STATIC_URL = "/static/"
AUTH_USER_MODEL = "gaehsnitz.User"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"gaehsnitz.auth_backends.PinBackend",
]
LOGIN_URL = "/suff/"
-1
View File
@@ -3,6 +3,5 @@ from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("suff/", include(("gaehsnitz.suff_urls", "suff"))),
path("", include(("gaehsnitz.urls", "gaehsnitz"))),
]
+1 -1
View File
@@ -2,6 +2,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gaehsnitzproject.settings")
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gaehsnitzproject.settings')
application = get_wsgi_application()
+2 -3
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gaehsnitzproject.settings")
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gaehsnitzproject.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +17,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == "__main__":
if __name__ == '__main__':
main()
-21
View File
@@ -1,21 +0,0 @@
[project]
name = "gaehsnitz"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"django==6.0.6",
"gunicorn==26.0.0",
"psycopg[binary]==3.3.4",
]
[dependency-groups]
dev = [
"ruff==0.15.16",
]
[tool.ruff]
target-version = "py314"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
+5 -2
View File
@@ -1,4 +1,7 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended", ":configMigration"],
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":configMigration"
]
}
+3
View File
@@ -0,0 +1,3 @@
django==6.0.1
gunicorn==25.0.1
psycopg[binary]==3.3.2
-105
View File
@@ -1,105 +0,0 @@
# Suff drink booking tool
Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS.
## Auth
- `User.pin` (hashed CharField, 3 digits), separate from `password`. Strong password stays for `/admin/`.
- `PinBackend` authenticates by `username` + `pin`. `ModelBackend` first in `AUTHENTICATION_BACKENDS` so admin still needs strong password.
- PIN reset is **crew-only**. No self-reset, no random PINs, PINs never displayed.
## Name flow
Username = `slugify(input)`. POST name:
- not found → `create` mode → mandatory 3-digit PIN → create user → login
- found, has PIN → `login` mode → enter PIN
- 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
- `/suff/me/` shows: greeting, total/paid/open balance, drink grid, day-grouped history.
- Single form wraps two radio buttons (`booking_mode`) + all drink buttons (`name="drink_id"`).
- `booking_mode` values: `normal` (default, no radio selected), `for_free`, `cash_paid`.
- `+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)
- Phases: `before` / `booking` / `closed`.
- Test window: 2026-05-15 → 2026-05-31. Original festival: 2026-06-11 → 2026-06-14.
- `closed` shows static page; outside booking, all action endpoints redirect or 404.
- `settings.PRODUCTION=False` forces `booking`.
## 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
- `gaehsnitz/auth_backends.py``PinBackend`
- `gaehsnitz/suff.py` — all suff views + phase + crew helpers
- `gaehsnitz/suff_urls.py` — routes
- `gaehsnitz/admin.py``SetPinForm` + `set_pin_view`
- `gaehsnitz/templates/suff/{base,name,pin,no_pin,me,pay,dashboard,closed,confirm_delete,staff_index,staff_user,staff_pay,staff_register,staff_pin_reset,staff_confirm_delete}.html`
- `gaehsnitz/static/suff/{style.css,favicon.svg}`
- `gaehsnitz/migrations/0009_anonymous_user.py` — seeds `anonym`
## Frontend
Mobile-first dark theme. `#161616` bg, `#EE9933`/`#FFCC77` amber, `#885522` brown borders, `#66ddee` cyan for crew target. Drink buttons gradient-colored per category. Toast banner for booking confirmation. `:active` scale feedback. SVG favicon.
## Open ideas / next session
### 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
- PWA manifest for add-to-homescreen
- Drink icons/emoji per type
- Style phase pages (`before` / `closed`)
- Per-user QR for fast crew lookup at the bar