Compare commits
1 Commits
main
..
a0f2d6eb04
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f2d6eb04 |
@@ -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
@@ -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
@@ -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
@@ -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('{} <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>")
|
||||
|
||||
@@ -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)}")
|
||||
+17
-39
@@ -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 ---------------
|
||||
|
||||
+17
-35
@@ -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)})")
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
+10
-9
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-14 18:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gaehsnitz", "0003_consumption_created_at_user_pin"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="year",
|
||||
field=models.PositiveSmallIntegerField(default=2024),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="name",
|
||||
field=models.CharField(max_length=32),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="drink",
|
||||
unique_together={("name", "year")},
|
||||
),
|
||||
]
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-14 19:15
|
||||
|
||||
import django.db.models.deletion
|
||||
import gaehsnitz.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gaehsnitz", "0004_drink_year"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="consumption",
|
||||
options={"verbose_name": "Konsum", "verbose_name_plural": "Konsum"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="donation",
|
||||
options={"verbose_name": "Spende", "verbose_name_plural": "Spenden"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="drink",
|
||||
options={"verbose_name": "Getränk", "verbose_name_plural": "Getränke"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="payment",
|
||||
options={"verbose_name": "Ausgabe", "verbose_name_plural": "Ausgaben"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="user",
|
||||
options={"verbose_name": "Benutzer", "verbose_name_plural": "Benutzer"},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="consumption",
|
||||
name="amount",
|
||||
field=models.PositiveSmallIntegerField(verbose_name="Anzahl"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="consumption",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name="Gebucht am"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="consumption",
|
||||
name="day",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")], verbose_name="Tag"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="consumption",
|
||||
name="drink",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="consumption_list",
|
||||
related_query_name="consumption",
|
||||
to="gaehsnitz.drink",
|
||||
verbose_name="Getränk",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="consumption",
|
||||
name="for_free",
|
||||
field=models.BooleanField(default=False, verbose_name="Gratis"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="consumption",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="consumption_list",
|
||||
related_query_name="consumption",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Benutzer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="donation",
|
||||
name="amount",
|
||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="donation",
|
||||
name="date",
|
||||
field=models.DateField(verbose_name="Datum"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="donation",
|
||||
name="note",
|
||||
field=models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="bottle_size",
|
||||
field=models.FloatField(verbose_name="Flaschengröße (l)"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="bottles_per_crate",
|
||||
field=models.PositiveSmallIntegerField(verbose_name="Flaschen pro Kasten"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="crates_ordered",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
|
||||
verbose_name="Kästen bestellt",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="crates_purchased",
|
||||
field=models.PositiveSmallIntegerField(verbose_name="Kästen gekauft"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="crates_returned",
|
||||
field=models.PositiveSmallIntegerField(verbose_name="Kästen leer zurück"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="deposit_per_crate",
|
||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Pfand pro Kasten"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="name",
|
||||
field=models.CharField(max_length=32, verbose_name="Name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="purchase_price_per_crate",
|
||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Einkaufspreis pro Kasten"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="sale_price_per_bottle",
|
||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Verkaufspreis pro Flasche"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="year",
|
||||
field=models.PositiveSmallIntegerField(default=2024, verbose_name="Jahr"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="payment",
|
||||
name="amount",
|
||||
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="payment",
|
||||
name="date",
|
||||
field=models.DateField(verbose_name="Datum"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="payment",
|
||||
name="purpose",
|
||||
field=models.CharField(max_length=64, verbose_name="Zweck"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="pin",
|
||||
field=models.CharField(blank=True, default="", max_length=128, verbose_name="PIN"),
|
||||
),
|
||||
]
|
||||
@@ -1,51 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-14 19:37
|
||||
|
||||
import django.db.models.deletion
|
||||
import gaehsnitz.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gaehsnitz", "0005_alter_consumption_options_alter_donation_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserPayment",
|
||||
fields=[
|
||||
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag")),
|
||||
(
|
||||
"method",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("cash", "Bar"),
|
||||
("paypal", "PayPal"),
|
||||
("bank", "Überweisung"),
|
||||
("other", "Sonstiges"),
|
||||
],
|
||||
max_length=16,
|
||||
verbose_name="Methode",
|
||||
),
|
||||
),
|
||||
("note", models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Bezahlt am")),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="user_payments",
|
||||
related_query_name="user_payment",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Benutzer",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Zahlung",
|
||||
"verbose_name_plural": "Zahlungen",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-14 20:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gaehsnitz", "0006_user_payment"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="drink",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("beer", "Bier"),
|
||||
("radler", "Radler"),
|
||||
("alc_free_beer", "Bier alkoholfrei"),
|
||||
("soft", "Softdrink"),
|
||||
("water", "Wasser"),
|
||||
],
|
||||
default="beer",
|
||||
max_length=16,
|
||||
verbose_name="Kategorie",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-14 20:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gaehsnitz", "0007_drink_category"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="drink",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("beer", "Bier"),
|
||||
("radler", "Radler"),
|
||||
("alc_free_beer", "Bier alkoholfrei"),
|
||||
("alc_free_radler", "Radler alkoholfrei"),
|
||||
("soft", "Softdrink"),
|
||||
("water", "Wasser"),
|
||||
],
|
||||
default="beer",
|
||||
max_length=16,
|
||||
verbose_name="Kategorie",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
ANONYMOUS_USERNAME = "anonym"
|
||||
|
||||
|
||||
def create_anonymous(apps, schema_editor):
|
||||
User = apps.get_model("gaehsnitz", "User")
|
||||
User.objects.get_or_create(
|
||||
username=ANONYMOUS_USERNAME,
|
||||
defaults={"pin": "", "is_staff": False, "is_superuser": False},
|
||||
)
|
||||
|
||||
|
||||
def delete_anonymous(apps, schema_editor):
|
||||
User = apps.get_model("gaehsnitz", "User")
|
||||
User.objects.filter(username=ANONYMOUS_USERNAME).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gaehsnitz", "0008_alter_drink_category"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_anonymous, delete_anonymous),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-09 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gaehsnitz', '0009_anonymous_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='drink',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('beer', 'Bier'), ('radler', 'Radler'), ('sekt', 'Sekt'), ('alc_free_beer', 'Bier alkoholfrei'), ('alc_free_radler', 'Radler alkoholfrei'), ('soft', 'Softdrink'), ('water', 'Wasser')], default='beer', max_length=16, verbose_name='Kategorie'),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-20 12:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gaehsnitz', '0010_add_sekt_category'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='method',
|
||||
field=models.CharField(choices=[('cash', 'Bar'), ('card', 'EC-Karte'), ('paypal', 'PayPal'), ('bank', 'Überweisung')], default='paypal', max_length=16, verbose_name='Methode'),
|
||||
),
|
||||
]
|
||||
+26
-148
@@ -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 |
@@ -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; }
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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})
|
||||
@@ -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>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url 'admin:gaehsnitz_user_changelist' %}">Users</a>
|
||||
› <a href="{% url 'admin:gaehsnitz_user_change' original.pk %}">{{ original }}</a>
|
||||
› 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 & 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 & 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 & 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 & 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 & 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 & 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>→ 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 & 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 & 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 %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "gaehsnitz/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>🛠 Alles noch in Planung</h2>
|
||||
<p>Bald gibt's mehr Infos.</p>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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>🛠 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 & 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 & 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 %}
|
||||
@@ -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 😎</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 ❤ 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 & 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>→ 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>🛠 Donnerstag 06.</h2>
|
||||
<p>ganz viel Aufbau</p>
|
||||
|
||||
<h2>🛠 Freitag 07.</h2>
|
||||
<p>Warm-Up</p>
|
||||
|
||||
<h2>🛠 Samstag 08.</h2>
|
||||
<p>ganz viel Live-Mucke</p>
|
||||
|
||||
<h2>🛠 Sonntag 09.</h2>
|
||||
<p>ganz viel Abbau</p>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>PIN ändern</h2>
|
||||
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:change_pin' %}">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Neue PIN:
|
||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}" maxlength="3"
|
||||
minlength="3" required autofocus autocomplete="off" />
|
||||
</label>
|
||||
<button type="submit">PIN speichern</button>
|
||||
</form>
|
||||
|
||||
<p><a href="{% url 'suff:me' %}">Abbrechen</a></p>
|
||||
{% endblock %}
|
||||
@@ -1,10 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if phase == "before" %}
|
||||
<p>Hier könnt ihr während des Festivals eure Getränke selbst buchen und den Überblick über eure Rechnung behalten — die digitale Strichliste.</p>
|
||||
<p>Das Tool startet am <strong>{{ booking_start|date:"d.m.Y" }}</strong> — schaut dann nochmal rein!</p>
|
||||
{% else %}
|
||||
<p>Das Festival ist vorbei. Das Tool ist jetzt deaktiviert.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Buchung löschen?</h2>
|
||||
|
||||
<p>
|
||||
Willst du wirklich
|
||||
<strong>{{ consumption.drink.name }}</strong>
|
||||
({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %})
|
||||
von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %}
|
||||
löschen?
|
||||
</p>
|
||||
|
||||
<form method="post" action="{% url 'suff:delete_consumption' consumption.id %}" class="confirm-actions">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
||||
<a href="{% url 'suff:me' %}" class="btn-secondary">Nein, zurück</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,21 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Zahlung löschen?</h2>
|
||||
|
||||
<p>
|
||||
Willst du wirklich diese Zahlung löschen?
|
||||
</p>
|
||||
|
||||
<section class="total-box">
|
||||
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} – {{ payment.note }}{% endif %}</span>
|
||||
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
|
||||
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
|
||||
</section>
|
||||
|
||||
<form method="post" action="{% url 'suff:delete_payment' payment.id %}" class="confirm-actions">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
||||
<a href="{% url 'suff:pay' %}" class="btn-secondary">Nein, zurück</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,257 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
{% load money %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Dashboard {{ year }}</h2>
|
||||
|
||||
<section>
|
||||
<h3>Finanzen</h3>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" style="width: {{ finance_pct_capped }}%;"></div>
|
||||
<span class="progress-label">{{ finance_pct }}%</span>
|
||||
</div>
|
||||
<ul class="history">
|
||||
<li class="fin-row">
|
||||
<span class="hist-what"><b>Ausgaben gesamt</b></span>
|
||||
<span class="hist-price"><b>{{ total_costs|euro }}</b></span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what"><b>Einnahmen gesamt</b></span>
|
||||
<span class="hist-price"><b>{{ income_total|euro }}</b></span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what"><b>Bilanz</b></span>
|
||||
<span class="hist-price"><b>{{ finance_balance|euro }}</b></span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="history history-sub">
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Spenden (Donations)</span>
|
||||
<span class="hist-price">{{ total_donations|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Cashless (User-Tab)</span>
|
||||
<span class="hist-price">{{ user_payments_total|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Kasse (bar, nach Vorschuss)</span>
|
||||
<span class="hist-price">{{ cash_net|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Kassen-Vorschuss</span>
|
||||
<span class="hist-price">{{ cash_prefill|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Einkaufspreis Getränke</span>
|
||||
<span class="hist-price">{{ purchase_cost|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Verkaufserlös Getränke</span>
|
||||
<span class="hist-price">{{ sales_revenue|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Gratis-Getränke (EK-Wert)</span>
|
||||
<span class="hist-price">{{ free_drinks_value|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Unverkauft (EK-Wert)</span>
|
||||
<span class="hist-price">{{ unsold_purchase_value|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Unverkauft (potenzieller VK)</span>
|
||||
<span class="hist-price">{{ unsold_sale_value|euro }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Getränke</h3>
|
||||
<ul class="history">
|
||||
{% for d in drink_rows %}
|
||||
<li class="dash-row">
|
||||
<span class="hist-what"><b>{{ d.name }}</b></span>
|
||||
<span class="hist-when">{{ d.sold }}/{{ d.total }} verkauft</span>
|
||||
<span class="hist-price">{{ d.balance|euro }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Offene Beträge</h3>
|
||||
{% if user_rows %}
|
||||
<ul class="history">
|
||||
{% for u in user_rows %}
|
||||
<li>
|
||||
<span class="hist-when">{{ u.username }}</span>
|
||||
<span class="hist-what">{{ u.consumed|euro }} − {{ u.paid|euro }}</span>
|
||||
<span class="hist-price">{{ u.open|euro }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted-left">Niemand hat etwas konsumiert.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Trinker</h3>
|
||||
<ul class="history">
|
||||
{% if top_spender %}
|
||||
<li>
|
||||
<span class="hist-when">Top-Zecher</span>
|
||||
<span class="hist-what">{{ top_spender.username }}</span>
|
||||
<span class="hist-price">{{ top_spender.total|euro }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if variety_champ %}
|
||||
<li>
|
||||
<span class="hist-when">Vielfalt-Champion</span>
|
||||
<span class="hist-what">{{ variety_champ.user__username }}</span>
|
||||
<span class="hist-price">{{ variety_champ.distinct }} Sorten</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for f in top_per_day %}
|
||||
<li>
|
||||
<span class="hist-when">Top {{ f.label }}</span>
|
||||
<span class="hist-what">{{ f.username }}</span>
|
||||
<span class="hist-price">{{ f.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Getränke</h3>
|
||||
<ul class="history">
|
||||
{% if top_drink %}
|
||||
<li>
|
||||
<span class="hist-when">Top-Getränk</span>
|
||||
<span class="hist-what">{{ top_drink.name }}</span>
|
||||
<span class="hist-price">{{ top_drink.amount }}x</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<span class="hist-when">Bier-Anteil</span>
|
||||
<span class="hist-what">am Konsum</span>
|
||||
<span class="hist-price">{{ beer_share }}%</span>
|
||||
</li>
|
||||
{% if alcfree_top %}
|
||||
<li>
|
||||
<span class="hist-when">{{ alcfree_top.label }}</span>
|
||||
<span class="hist-what">{{ alcfree_top.username }}</span>
|
||||
<span class="hist-price">{{ alcfree_top.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if radler_top %}
|
||||
<li>
|
||||
<span class="hist-when">{{ radler_top.label }}</span>
|
||||
<span class="hist-what">{{ radler_top.username }}</span>
|
||||
<span class="hist-price">{{ radler_top.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if water_top %}
|
||||
<li>
|
||||
<span class="hist-when">{{ water_top.label }}</span>
|
||||
<span class="hist-what">{{ water_top.username }}</span>
|
||||
<span class="hist-price">{{ water_top.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Zeit</h3>
|
||||
<ul class="history">
|
||||
{% if first_cons %}
|
||||
<li>
|
||||
<span class="hist-when">Erste Buchung</span>
|
||||
<span class="hist-what">{{ first_cons.user.username }} – {{ first_cons.drink.name }}</span>
|
||||
<span class="hist-price">{{ first_cons.created_at|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if last_cons %}
|
||||
<li>
|
||||
<span class="hist-when">Letzter Schluck</span>
|
||||
<span class="hist-what">{{ last_cons.user.username }} – {{ last_cons.drink.name }}</span>
|
||||
<span class="hist-price">{{ last_cons.created_at|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if earliest_per_user %}
|
||||
<li>
|
||||
<span class="hist-when">Frühaufsteher</span>
|
||||
<span class="hist-what">{{ earliest_per_user.user__username }}</span>
|
||||
<span class="hist-price">{{ earliest_per_user.t|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if latest_per_user %}
|
||||
<li>
|
||||
<span class="hist-when">Nachtschwärmer</span>
|
||||
<span class="hist-what">{{ latest_per_user.user__username }}</span>
|
||||
<span class="hist-price">{{ latest_per_user.t|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if golden_hour %}
|
||||
<li>
|
||||
<span class="hist-when">Goldene Stunde</span>
|
||||
<span class="hist-what">{{ golden_hour.label }}</span>
|
||||
<span class="hist-price">{{ golden_hour.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if busiest_day %}
|
||||
<li>
|
||||
<span class="hist-when">Härtester Tag</span>
|
||||
<span class="hist-what">{{ busiest_day.label }}</span>
|
||||
<span class="hist-price">{{ busiest_day.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Geld</h3>
|
||||
<ul class="history">
|
||||
{% if biggest_tip %}
|
||||
<li>
|
||||
<span class="hist-when">Bigspender-Trinkgeld</span>
|
||||
<span class="hist-what">{{ biggest_tip.username }}</span>
|
||||
<span class="hist-price">{{ biggest_tip.tip|euro }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<span class="hist-when">Eintrittsspenden gesamt</span>
|
||||
<span class="hist-what">aus Überzahlungen</span>
|
||||
<span class="hist-price">{{ total_entry|euro }}</span>
|
||||
</li>
|
||||
{% for method, sum in method_split.items %}
|
||||
<li>
|
||||
<span class="hist-when">Zahlungen {{ method }}</span>
|
||||
<span class="hist-what"></span>
|
||||
<span class="hist-price">{{ sum|euro }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Gratis</h3>
|
||||
<ul class="history">
|
||||
<li>
|
||||
<span class="hist-when">Aufmerksamkeit des Hauses</span>
|
||||
<span class="hist-what">Gratis-Flaschen</span>
|
||||
<span class="hist-price">{{ free_total_count }}x</span>
|
||||
</li>
|
||||
{% if top_free_recipient %}
|
||||
<li>
|
||||
<span class="hist-when">Glücklicher Empfänger</span>
|
||||
<span class="hist-what">{{ top_free_recipient.user__username }}</span>
|
||||
<span class="hist-price">{{ top_free_recipient.amount }} gratis</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Party vorbei!</h2>
|
||||
<p>
|
||||
Der Name <b>{{ username }}</b> existiert noch nicht —
|
||||
neue Accounts können nicht mehr angelegt werden, weil das Festival vorbei ist.
|
||||
</p>
|
||||
<p>
|
||||
Du hast schon einen Account? Gib deinen Namen nochmal ein.
|
||||
</p>
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,118 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Spenden</h2>
|
||||
|
||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
||||
{% if paid and open_balance <= 0 %}
|
||||
<span class="total-settled">Bezahlt ✓</span>
|
||||
<div class="total-breakdown">
|
||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
{% if open_balance < 0 %}
|
||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
||||
{% else %}
|
||||
<span>Genau bezahlt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="total-label">Deine Rechnung</span>
|
||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||
{% if paid %}
|
||||
<span class="total-label">Bezahlt</span>
|
||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
<span class="total-label">Offen (Drinks)</span>
|
||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<p class="intro">
|
||||
Dein Beitrag deckt die <strong>Drinks</strong> und deine
|
||||
<strong>Eintrittsspende</strong>. Die Vorschläge unten runden deinen
|
||||
offenen Drink-Betrag auf die nächsten 5 € und legen 10–30 €
|
||||
Eintritt drauf.
|
||||
</p>
|
||||
<p class="intro">
|
||||
Du darfst gerne <strong>weniger</strong> geben, wenn das gerade besser
|
||||
passt – kein Problem. Und wenn du <strong>mehr</strong> geben kannst,
|
||||
hilft uns das sehr, die Kosten für Bands, Toiletten usw. zu
|
||||
decken. So oder so: <strong>danke, dass du da bist!</strong>
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h3>Vorschläge</h3>
|
||||
<div class="amount-grid">
|
||||
{% for s in suggestions %}
|
||||
<a href="?amount={{ s.amount }}" class="amount-btn{% if prefill_amount == s.amount %} amount-btn-active{% endif %}">
|
||||
<span class="amount-main">{{ s.amount }} €</span>
|
||||
<span class="amount-sub">→ {{ s.entry|floatformat:2 }} € Eintritt</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if open_balance > 0 %}
|
||||
<a href="?amount={{ open_balance_url }}" class="amount-btn amount-btn-weak">
|
||||
<span class="amount-main">{{ open_balance|floatformat:2 }} €</span>
|
||||
<span class="amount-sub">→ Nur Drinks</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:pay' %}" class="pay-form">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Betrag (€)
|
||||
<input type="text" name="amount" inputmode="decimal" pattern="[0-9]+([.,][0-9]{1,2})?"
|
||||
value="{% if prefill_value %}{{ prefill_value }}{% else %}{{ suggestions.0.amount }}{% endif %}" required />
|
||||
</label>
|
||||
<label>
|
||||
Methode
|
||||
<select name="method" required>
|
||||
<option value="">— wählen —</option>
|
||||
{% for value, label in payment_methods %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Notiz (optional)
|
||||
<input type="text" name="note" maxlength="64" />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Zahlung eintragen</button>
|
||||
</form>
|
||||
|
||||
<div class="link-row">
|
||||
<a href="https://www.paypal.com/paypalme/lotharwiener" target="_blank" rel="noopener noreferrer" class="link-btn link-btn-paypal">
|
||||
PayPal öffnen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if user_payments %}
|
||||
<section>
|
||||
<h3>Bisherige Zahlungen</h3>
|
||||
<ul class="history">
|
||||
{% for p in user_payments %}
|
||||
<li>
|
||||
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
|
||||
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %}</span>
|
||||
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
|
||||
{% if phase == "booking" %}
|
||||
<a href="{% url 'suff:delete_payment' p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -1,45 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Benutzer wählen</h2>
|
||||
|
||||
<p>Buchung oder Zahlung im Auftrag eines Benutzers eintragen.</p>
|
||||
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:staff_register' %}" class="link-btn">Neuen Benutzer anlegen</a>
|
||||
</div>
|
||||
|
||||
{% if anonymous %}
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:staff_user' anonymous.username %}" class="link-btn">
|
||||
Anonymer Gast (Bar)
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h3>Registrierte Benutzer</h3>
|
||||
{% if users %}
|
||||
<ul class="history">
|
||||
{% for u in users %}
|
||||
<li>
|
||||
<span class="hist-what">
|
||||
{% if u.username == request.user.username %}
|
||||
<span class="user-self">{{ u.username }} <span class="muted-inline">(das bist du)</span></span>
|
||||
{% else %}
|
||||
<a href="{% url 'suff:staff_user' u.username %}">{{ u.username }}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">Keine Benutzer.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,81 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Zahlung für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
||||
|
||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
||||
{% if paid and open_balance <= 0 %}
|
||||
<span class="total-settled">Bezahlt ✓</span>
|
||||
<div class="total-breakdown">
|
||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
{% if open_balance < 0 %}
|
||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
||||
{% else %}
|
||||
<span>Genau bezahlt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="total-label">Rechnung</span>
|
||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||
{% if paid %}
|
||||
<span class="total-label">Bezahlt</span>
|
||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
<span class="total-label">Offen</span>
|
||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_pay' tab_user.username %}" class="pay-form">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Betrag (€)
|
||||
<input type="number" name="amount" step="0.01" min="0.01"
|
||||
value="{% if open_balance > 0 %}{{ open_balance|floatformat:2 }}{% else %}0.00{% endif %}" required />
|
||||
</label>
|
||||
<label>
|
||||
Methode
|
||||
<select name="method" required>
|
||||
<option value="">— wählen —</option>
|
||||
{% for value, label in payment_methods %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Notiz (optional)
|
||||
<input type="text" name="note" maxlength="64" />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Zahlung eintragen</button>
|
||||
</form>
|
||||
|
||||
{% if user_payments %}
|
||||
<section>
|
||||
<h3>Bisherige Zahlungen</h3>
|
||||
<ul class="history">
|
||||
{% for p in user_payments %}
|
||||
<li>
|
||||
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
|
||||
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %}</span>
|
||||
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
|
||||
{% if phase == "booking" %}
|
||||
<a href="{% url 'suff:staff_delete_payment' tab_user.username p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,24 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>PIN setzen für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
||||
|
||||
<p>Neue 3-stellige PIN eingeben. Eine bestehende PIN wird überschrieben.</p>
|
||||
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_pin_reset' tab_user.username %}">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Neue PIN
|
||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
|
||||
maxlength="3" minlength="3" required autofocus autocomplete="off" />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">PIN speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Neuen Benutzer anlegen</h2>
|
||||
|
||||
<p>Name eingeben. PIN ist optional — kann später bei Bedarf gesetzt werden.</p>
|
||||
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_register' %}">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" maxlength="150" required autofocus
|
||||
value="{{ prefill_name }}" />
|
||||
</label>
|
||||
<label>
|
||||
PIN (optional, 3 Ziffern)
|
||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
|
||||
maxlength="3" autocomplete="off" value="{{ prefill_pin }}" />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Anlegen</button>
|
||||
</form>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,132 +0,0 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Buchen für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
||||
|
||||
{% if booked_drink %}
|
||||
<div class="toast" role="status">
|
||||
Gebucht: +1 {{ booked_drink.name }}{% if booked_free %} (gratis){% elif is_anonymous_target %} (bar bezahlt){% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if paid_toast %}
|
||||
<div class="toast" role="status">
|
||||
Zahlung gespeichert.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_anonymous_target %}
|
||||
<p class="muted">Anonyme Buchungen werden automatisch als bar bezahlt eingetragen.</p>
|
||||
{% else %}
|
||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
||||
{% if paid and open_balance <= 0 %}
|
||||
<span class="total-settled">Bezahlt ✓</span>
|
||||
<div class="total-breakdown">
|
||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
{% if open_balance < 0 %}
|
||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
||||
{% else %}
|
||||
<span>Genau bezahlt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="total-label">Rechnung</span>
|
||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||
{% if paid %}
|
||||
<span class="total-label">Bezahlt</span>
|
||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||
<span class="total-label">Offen</span>
|
||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<p><a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-primary">Zahlung eintragen</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if phase == "booking" %}
|
||||
<section>
|
||||
<h3>Getränk buchen</h3>
|
||||
<form method="post" action="{% url 'suff:staff_book' tab_user.username %}">
|
||||
{% csrf_token %}
|
||||
<label class="for-free-toggle">
|
||||
<input type="checkbox" name="booking_mode" value="for_free" />
|
||||
<span>Gratis (z.B. Artists am Spieltag)</span>
|
||||
</label>
|
||||
{% if not is_anonymous_target %}
|
||||
<label class="for-free-toggle">
|
||||
<input type="checkbox" name="booking_mode" value="cash_paid" />
|
||||
<span>Direkt bar bezahlt</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
<script>
|
||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(other) {
|
||||
if (other !== cb) other.checked = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% regroup drinks by alcohol_label as drink_groups %}
|
||||
{% for group in drink_groups %}
|
||||
<h4 class="drink-group-heading">{{ group.grouper }}</h4>
|
||||
<div class="drink-grid">
|
||||
{% for drink in group.list %}
|
||||
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
|
||||
<span class="drink-plus">+1</span>
|
||||
<span class="drink-name">{{ drink.name }}</span>
|
||||
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h3>Bisher gebucht</h3>
|
||||
{% if consumption_list %}
|
||||
{% regroup consumption_list by get_day_display as day_groups %}
|
||||
{% for group in day_groups %}
|
||||
<div class="day-group">
|
||||
<h4 class="day-heading">{{ group.grouper }}</h4>
|
||||
<ul class="history">
|
||||
{% for c in group.list %}
|
||||
<li>
|
||||
<span class="hist-when">{% if c.created_at %}{{ c.created_at|date:"H:i" }}{% else %}—{% endif %}</span>
|
||||
<span class="hist-what">{{ c.drink.name }}</span>
|
||||
<span class="hist-price">
|
||||
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
|
||||
</span>
|
||||
{% if phase == "booking" %}
|
||||
<a href="{% url 'suff:staff_delete_consumption' tab_user.username c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p class="empty-emoji">🍺</p>
|
||||
<p>Noch nichts gebucht.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="link-row">
|
||||
{% if not is_anonymous_target %}
|
||||
<a href="{% url 'suff:staff_pin_reset' tab_user.username %}" class="link-btn link-btn-secondary">PIN setzen / zurücksetzen</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Anderen Benutzer wählen</a>
|
||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück zu mir</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+5
-3
@@ -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
@@ -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"
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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"))),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
django==6.0.1
|
||||
gunicorn==25.0.0
|
||||
psycopg[binary]==3.3.2
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user