Compare commits
7 Commits
e47b03003d
...
97085b699b
| Author | SHA1 | Date | |
|---|---|---|---|
| 97085b699b | |||
| 3e70a756d6 | |||
| b10e434d0c | |||
| 2b46c7cd54 | |||
| 2056d5bbc7 | |||
| b9c62babf1 | |||
| 4c9d041254 |
+177
-15
@@ -3,13 +3,18 @@ from django.contrib import admin, messages
|
|||||||
from django.contrib.admin.utils import unquote
|
from django.contrib.admin.utils import unquote
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
|
admin.site.unregister(Group)
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from gaehsnitz.models import Donation, Payment, Drink, Consumption
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from gaehsnitz.models import Donation, Payment, Drink, Consumption, UserPayment, current_year
|
||||||
from gaehsnitz.templatetags.money import euro
|
from gaehsnitz.templatetags.money import euro
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -20,6 +25,12 @@ class ConsumptionInline(admin.TabularInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class UserPaymentInline(admin.TabularInline):
|
||||||
|
model = UserPayment
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ("created_at",)
|
||||||
|
|
||||||
|
|
||||||
class SetPinForm(forms.Form):
|
class SetPinForm(forms.Form):
|
||||||
pin = forms.CharField(
|
pin = forms.CharField(
|
||||||
label="Neue PIN (3 Ziffern)",
|
label="Neue PIN (3 Ziffern)",
|
||||||
@@ -36,7 +47,7 @@ class SetPinForm(forms.Form):
|
|||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
list_display = ("username", "consumed_drinks_price")
|
list_display = ("username", "consumed_drinks_price", "paid_amount", "open_balance")
|
||||||
ordering = ("username",)
|
ordering = ("username",)
|
||||||
list_filter = []
|
list_filter = []
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -50,14 +61,62 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("BALANCE", {"fields": ("consumed_drinks_price",)}),
|
(
|
||||||
|
"BILANZ",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"consumed_drinks_price",
|
||||||
|
"paid_amount",
|
||||||
|
"open_balance",
|
||||||
|
"drinks_breakdown",
|
||||||
|
"free_drinks_breakdown",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
readonly_fields = ("consumed_drinks_price", "pin_status")
|
readonly_fields = (
|
||||||
inlines = (ConsumptionInline,)
|
"consumed_drinks_price",
|
||||||
|
"paid_amount",
|
||||||
|
"open_balance",
|
||||||
|
"pin_status",
|
||||||
|
"drinks_breakdown",
|
||||||
|
"free_drinks_breakdown",
|
||||||
|
)
|
||||||
|
inlines = (UserPaymentInline, ConsumptionInline)
|
||||||
|
|
||||||
|
@admin.display(description="Konsumiert")
|
||||||
def consumed_drinks_price(self, user: User):
|
def consumed_drinks_price(self, user: User):
|
||||||
return euro(user.consumed_drinks_price)
|
return euro(user.consumed_drinks_price)
|
||||||
|
|
||||||
|
@admin.display(description="Bezahlt")
|
||||||
|
def paid_amount(self, user: User):
|
||||||
|
return euro(user.paid_amount)
|
||||||
|
|
||||||
|
@admin.display(description="Offener Betrag")
|
||||||
|
def open_balance(self, user: User):
|
||||||
|
return euro(user.open_balance)
|
||||||
|
|
||||||
|
def _breakdown(self, user: User, for_free: bool):
|
||||||
|
if user.pk is None:
|
||||||
|
return "-"
|
||||||
|
rows = (
|
||||||
|
user.consumption_list.filter(for_free=for_free, drink__year=current_year())
|
||||||
|
.values("drink__name")
|
||||||
|
.annotate(amount=Sum("amount"))
|
||||||
|
.order_by("drink__name")
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return "-"
|
||||||
|
return ", ".join(f"{r['amount']}x {r['drink__name']}" for r in rows)
|
||||||
|
|
||||||
|
@admin.display(description="Bezahlt")
|
||||||
|
def drinks_breakdown(self, user: User):
|
||||||
|
return self._breakdown(user, for_free=False)
|
||||||
|
|
||||||
|
@admin.display(description="Gratis")
|
||||||
|
def free_drinks_breakdown(self, user: User):
|
||||||
|
return self._breakdown(user, for_free=True)
|
||||||
|
|
||||||
@admin.display(description="PIN")
|
@admin.display(description="PIN")
|
||||||
def pin_status(self, user: User):
|
def pin_status(self, user: User):
|
||||||
status = "gesetzt" if user.pin else "nicht gesetzt"
|
status = "gesetzt" if user.pin else "nicht gesetzt"
|
||||||
@@ -104,10 +163,39 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context)
|
return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
class YearFilter(admin.SimpleListFilter):
|
||||||
|
title = "Jahr"
|
||||||
|
parameter_name = "year"
|
||||||
|
field_name = "created_at"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
years = model_admin.model.objects.dates(self.field_name, "year", order="DESC")
|
||||||
|
return [(y.year, str(y.year)) for y in years]
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value():
|
||||||
|
return queryset.filter(**{f"{self.field_name}__year": self.value()})
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserPayment)
|
||||||
|
class UserPaymentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("created_at", "user", "amount", "method", "note")
|
||||||
|
list_filter = ("method", YearFilter)
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
search_fields = ("user__username", "note")
|
||||||
|
|
||||||
|
@admin.display(ordering="amount")
|
||||||
|
def amount(self, payment: UserPayment):
|
||||||
|
return euro(payment.amount)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Donation)
|
@admin.register(Donation)
|
||||||
class DonationAdmin(admin.ModelAdmin):
|
class DonationAdmin(admin.ModelAdmin):
|
||||||
list_display = ("date", "amount", "note")
|
list_display = ("date", "amount", "note")
|
||||||
|
list_filter = ("date",)
|
||||||
ordering = ("-date",)
|
ordering = ("-date",)
|
||||||
|
search_fields = ("note",)
|
||||||
|
|
||||||
@admin.display(ordering="amount")
|
@admin.display(ordering="amount")
|
||||||
def amount(self, donation: Donation):
|
def amount(self, donation: Donation):
|
||||||
@@ -117,7 +205,9 @@ class DonationAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(Payment)
|
@admin.register(Payment)
|
||||||
class PaymentAdmin(admin.ModelAdmin):
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
list_display = ("date", "purpose", "amount")
|
list_display = ("date", "purpose", "amount")
|
||||||
|
list_filter = ("date",)
|
||||||
ordering = ("-date",)
|
ordering = ("-date",)
|
||||||
|
search_fields = ("purpose",)
|
||||||
|
|
||||||
@admin.display(ordering="amount")
|
@admin.display(ordering="amount")
|
||||||
def amount(self, payment: Payment):
|
def amount(self, payment: Payment):
|
||||||
@@ -126,31 +216,46 @@ class PaymentAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Drink)
|
@admin.register(Drink)
|
||||||
class DrinkAdmin(admin.ModelAdmin):
|
class DrinkAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "purchase_price_per_crate", "crates_purchased", "purchase_price_total")
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"year",
|
||||||
|
"crates_purchased",
|
||||||
|
"bottles_sold",
|
||||||
|
"bottles_remaining",
|
||||||
|
"purchase_price_total",
|
||||||
|
"balance",
|
||||||
|
)
|
||||||
|
list_filter = ("year",)
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name",)}),
|
(None, {"fields": ("name", "year")}),
|
||||||
(
|
(
|
||||||
"crates",
|
"Kästen",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"crates_ordered",
|
"crates_ordered",
|
||||||
"crates_purchased",
|
"crates_purchased",
|
||||||
|
"crates_full_returned",
|
||||||
"crates_returned",
|
"crates_returned",
|
||||||
|
"crates_remaining",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bottles",
|
"Flaschen",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"bottles_per_crate",
|
"bottles_per_crate",
|
||||||
"bottles_total",
|
"bottles_total",
|
||||||
"bottles_returned",
|
"bottles_returned",
|
||||||
|
"bottles_sold",
|
||||||
|
"bottles_given_away",
|
||||||
|
"bottles_consumed",
|
||||||
|
"bottles_remaining",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"amount",
|
"Menge",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"bottle_size",
|
"bottle_size",
|
||||||
@@ -160,17 +265,18 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"purchase",
|
"Einkauf",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"purchase_price_per_crate",
|
"purchase_price_per_crate",
|
||||||
"purchase_price_per_bottle",
|
"purchase_price_per_bottle",
|
||||||
"purchase_price_total",
|
"purchase_price_total",
|
||||||
|
"remaining_purchase_value",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"deposit",
|
"Pfand",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"deposit_per_crate",
|
"deposit_per_crate",
|
||||||
@@ -181,14 +287,12 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"sales",
|
"Verkauf",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"sale_price_per_bottle",
|
"sale_price_per_bottle",
|
||||||
"bottles_sold",
|
|
||||||
"sales_purchase_value",
|
"sales_purchase_value",
|
||||||
"sale_price_total",
|
"sale_price_total",
|
||||||
"bottles_given_away",
|
|
||||||
"giveaway_purchase_value",
|
"giveaway_purchase_value",
|
||||||
"balance",
|
"balance",
|
||||||
)
|
)
|
||||||
@@ -198,10 +302,15 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"bottles_total",
|
"bottles_total",
|
||||||
"bottles_returned",
|
"bottles_returned",
|
||||||
|
"bottles_consumed",
|
||||||
|
"bottles_remaining",
|
||||||
|
"crates_full_returned",
|
||||||
|
"crates_remaining",
|
||||||
"amount_per_crate",
|
"amount_per_crate",
|
||||||
"amount_total",
|
"amount_total",
|
||||||
"purchase_price_per_bottle",
|
"purchase_price_per_bottle",
|
||||||
"purchase_price_total",
|
"purchase_price_total",
|
||||||
|
"remaining_purchase_value",
|
||||||
"deposit_total",
|
"deposit_total",
|
||||||
"deposit_refund",
|
"deposit_refund",
|
||||||
"deposit_kept",
|
"deposit_kept",
|
||||||
@@ -213,29 +322,82 @@ class DrinkAdmin(admin.ModelAdmin):
|
|||||||
"balance",
|
"balance",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Kästen voll zurück")
|
||||||
|
def crates_full_returned(self, drink: Drink):
|
||||||
|
return drink.crates_full_returned
|
||||||
|
|
||||||
|
@admin.display(description="Kästen übrig")
|
||||||
|
def crates_remaining(self, drink: Drink):
|
||||||
|
return drink.crates_remaining
|
||||||
|
|
||||||
|
@admin.display(description="Flaschen gesamt")
|
||||||
|
def bottles_total(self, drink: Drink):
|
||||||
|
return drink.bottles_total
|
||||||
|
|
||||||
|
@admin.display(description="Flaschen leer zurück")
|
||||||
|
def bottles_returned(self, drink: Drink):
|
||||||
|
return drink.bottles_returned
|
||||||
|
|
||||||
|
@admin.display(description="Flaschen verkauft")
|
||||||
|
def bottles_sold(self, drink: Drink):
|
||||||
|
return drink.bottles_sold
|
||||||
|
|
||||||
|
@admin.display(description="Flaschen verschenkt")
|
||||||
|
def bottles_given_away(self, drink: Drink):
|
||||||
|
return drink.bottles_given_away
|
||||||
|
|
||||||
|
@admin.display(description="Flaschen konsumiert")
|
||||||
|
def bottles_consumed(self, drink: Drink):
|
||||||
|
return drink.bottles_consumed
|
||||||
|
|
||||||
|
@admin.display(description="Flaschen übrig")
|
||||||
|
def bottles_remaining(self, drink: Drink):
|
||||||
|
return drink.bottles_remaining
|
||||||
|
|
||||||
|
@admin.display(description="Menge pro Kasten (l)")
|
||||||
|
def amount_per_crate(self, drink: Drink):
|
||||||
|
return drink.amount_per_crate
|
||||||
|
|
||||||
|
@admin.display(description="Menge gesamt (l)")
|
||||||
|
def amount_total(self, drink: Drink):
|
||||||
|
return drink.amount_total
|
||||||
|
|
||||||
|
@admin.display(description="Einkaufspreis pro Flasche")
|
||||||
def purchase_price_per_bottle(self, drink: Drink):
|
def purchase_price_per_bottle(self, drink: Drink):
|
||||||
return euro(drink.purchase_price_per_bottle)
|
return euro(drink.purchase_price_per_bottle)
|
||||||
|
|
||||||
|
@admin.display(description="Einkaufspreis gesamt")
|
||||||
def purchase_price_total(self, drink: Drink):
|
def purchase_price_total(self, drink: Drink):
|
||||||
return euro(drink.purchase_price_total)
|
return euro(drink.purchase_price_total)
|
||||||
|
|
||||||
|
@admin.display(description="Einkaufswert übrig")
|
||||||
|
def remaining_purchase_value(self, drink: Drink):
|
||||||
|
return euro(drink.remaining_purchase_value)
|
||||||
|
|
||||||
|
@admin.display(description="Pfand gesamt")
|
||||||
def deposit_total(self, drink: Drink):
|
def deposit_total(self, drink: Drink):
|
||||||
return euro(drink.deposit_total)
|
return euro(drink.deposit_total)
|
||||||
|
|
||||||
|
@admin.display(description="Pfand zurück")
|
||||||
def deposit_refund(self, drink: Drink):
|
def deposit_refund(self, drink: Drink):
|
||||||
return euro(drink.deposit_refund)
|
return euro(drink.deposit_refund)
|
||||||
|
|
||||||
|
@admin.display(description="Pfand einbehalten")
|
||||||
def deposit_kept(self, drink: Drink):
|
def deposit_kept(self, drink: Drink):
|
||||||
return euro(drink.deposit_kept)
|
return euro(drink.deposit_kept)
|
||||||
|
|
||||||
|
@admin.display(description="Einkaufswert verkauft")
|
||||||
def sales_purchase_value(self, drink: Drink):
|
def sales_purchase_value(self, drink: Drink):
|
||||||
return euro(drink.sales_purchase_value)
|
return euro(drink.sales_purchase_value)
|
||||||
|
|
||||||
|
@admin.display(description="Verkaufserlös")
|
||||||
def sale_price_total(self, drink: Drink):
|
def sale_price_total(self, drink: Drink):
|
||||||
return euro(drink.sale_price_total)
|
return euro(drink.sale_price_total)
|
||||||
|
|
||||||
|
@admin.display(description="Einkaufswert verschenkt")
|
||||||
def giveaway_purchase_value(self, drink: Drink):
|
def giveaway_purchase_value(self, drink: Drink):
|
||||||
return euro(drink.giveaway_purchase_value)
|
return euro(drink.giveaway_purchase_value)
|
||||||
|
|
||||||
|
@admin.display(description="Bilanz")
|
||||||
def balance(self, drink: Drink):
|
def balance(self, drink: Drink):
|
||||||
return mark_safe(f"<b>{euro(drink.balance)}</b>")
|
return mark_safe(f"<b>{euro(drink.balance)}</b>")
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
from django.core.management import BaseCommand
|
|
||||||
from django.db.models import Sum
|
|
||||||
|
|
||||||
from gaehsnitz.models import Drink
|
|
||||||
from gaehsnitz.templatetags.money import euro
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
for drink in Drink.objects.all():
|
|
||||||
print(f"--- {drink.name} ---")
|
|
||||||
|
|
||||||
print(f"Kästen (laut Abrechnung):")
|
|
||||||
ordered = drink.crates_ordered
|
|
||||||
print(f" bestellt: {ordered}")
|
|
||||||
purchased = drink.crates_purchased
|
|
||||||
print(f" gekauft: {purchased}")
|
|
||||||
full_ret = ordered - purchased
|
|
||||||
print(f" voll zurück: {full_ret}")
|
|
||||||
empty_ret = drink.crates_returned
|
|
||||||
print(f" leer zurück: {empty_ret}")
|
|
||||||
remaining = purchased - empty_ret
|
|
||||||
print(f" übrig: {remaining}")
|
|
||||||
|
|
||||||
print("Flaschen (laut Strichliste):")
|
|
||||||
bought = drink.bottles_total
|
|
||||||
print(f" gekauft: {bought}")
|
|
||||||
consumed = drink.consumption_list.aggregate(sum=Sum("amount"))["sum"] or 0
|
|
||||||
print(f" getrunken: {consumed}")
|
|
||||||
remaining = bought - consumed
|
|
||||||
print(f" übrig: {remaining}")
|
|
||||||
purchase_value = remaining * drink.purchase_price_per_bottle
|
|
||||||
print(f" Wert: {euro(purchase_value)}")
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from gaehsnitz.models import Drink, Payment
|
||||||
|
|
||||||
|
PAYMENTS = [
|
||||||
|
# purpose, date, amount
|
||||||
|
("Toiletten", date(2026, 5, 6), 210.01),
|
||||||
|
("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 5, 18), 400.00),
|
||||||
|
("Band: Six Good Years", date(2026, 6, 12), 150.00),
|
||||||
|
("Band: Melo-Komplott", date(2026, 6, 12), 100.00),
|
||||||
|
("Band: Mörtel", date(2026, 6, 12), 150.00),
|
||||||
|
("Band: Kotpiloten", date(2026, 6, 13), 150.00),
|
||||||
|
("Band: Knast", date(2026, 6, 13), 150.00),
|
||||||
|
("Band: Quast", date(2026, 6, 13), 300.00),
|
||||||
|
]
|
||||||
|
|
||||||
|
DRINKS = [
|
||||||
|
# name, category, crates, btl/crate, size, price/crate, deposit/crate, sale/btl
|
||||||
|
("Sternburg Export", "beer", 12, 20, 0.5, 10.99, 3.10, 2.00),
|
||||||
|
("Ur-Krostitzer", "beer", 5, 20, 0.5, 16.49, 3.10, 2.50),
|
||||||
|
("Budweiser", "beer", 5, 20, 0.5, 20.99, 3.10, 2.50),
|
||||||
|
("Altenburger Helles", "beer", 5, 20, 0.5, 15.99, 4.50, 2.50),
|
||||||
|
("Feldschl. Radler", "radler", 2, 20, 0.5, 14.99, 3.10, 2.50),
|
||||||
|
("Lübzer Grapef. 0,0", "alc_free_radler", 1, 20, 0.5, 17.99, 3.10, 2.50),
|
||||||
|
("Freiberger 0,0", "alc_free_beer", 4, 20, 0.5, 15.49, 3.10, 2.50),
|
||||||
|
("Club Mate", "soft", 2, 20, 0.5, 17.49, 4.50, 2.50),
|
||||||
|
("Vita Cola", "soft", 2, 12, 1.0, 10.99, 3.30, 2.50),
|
||||||
|
("Paulaner Spezi", "soft", 2, 20, 0.5, 17.99, 3.10, 2.50),
|
||||||
|
("Wasser", "water", 10, 12, 1.0, 5.99, 3.30, 1.50),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Seed Drink rows for the 2026 festival from the supplier invoice."
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
for purpose, day, amount in PAYMENTS:
|
||||||
|
obj, created = Payment.objects.update_or_create(
|
||||||
|
purpose=purpose,
|
||||||
|
date=day,
|
||||||
|
defaults={"amount": amount},
|
||||||
|
)
|
||||||
|
self.stdout.write(f"{'created' if created else 'updated'}: {obj.purpose} ({obj.date})")
|
||||||
|
|
||||||
|
for name, category, crates, btl, size, price, deposit, sale in DRINKS:
|
||||||
|
obj, created = Drink.objects.update_or_create(
|
||||||
|
name=name,
|
||||||
|
year=2026,
|
||||||
|
defaults={
|
||||||
|
"category": category,
|
||||||
|
"crates_ordered": crates,
|
||||||
|
"crates_purchased": crates,
|
||||||
|
"crates_returned": 0,
|
||||||
|
"purchase_price_per_crate": price,
|
||||||
|
"deposit_per_crate": deposit,
|
||||||
|
"bottles_per_crate": btl,
|
||||||
|
"bottle_size": size,
|
||||||
|
"sale_price_per_bottle": sale,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.stdout.write(f"{'created' if created else 'updated'}: {obj.name}")
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.management import BaseCommand
|
|
||||||
from django.db.models import Sum
|
|
||||||
|
|
||||||
from gaehsnitz.models import Payment, Donation
|
|
||||||
from gaehsnitz.templatetags.money import euro
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
all_donations_sum = Donation.objects.all().aggregate(sum=Sum("amount"))["sum"]
|
|
||||||
print(f"Alle Spenden/Zahlungen: {euro(all_donations_sum)}")
|
|
||||||
all_payments_sum = Payment.objects.all().aggregate(sum=Sum("amount"))["sum"]
|
|
||||||
print(f"Alle Ausgaben: {euro(all_payments_sum)}")
|
|
||||||
balance = all_donations_sum - all_payments_sum
|
|
||||||
print("-------------------------")
|
|
||||||
print(f"Bilanz: {euro(balance)}")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.management import BaseCommand
|
|
||||||
from django.db.models import Sum
|
|
||||||
|
|
||||||
from gaehsnitz.models import Drink
|
|
||||||
from gaehsnitz.templatetags.money import euro
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
id_to_name = {d.id: d.name for d in Drink.objects.all()}
|
|
||||||
for user in User.objects.all().order_by("username"):
|
|
||||||
to_pay = user.consumed_drinks_price
|
|
||||||
if to_pay != 0:
|
|
||||||
paid_consumption = user.consumption_list.filter(for_free=False)
|
|
||||||
drink_list = []
|
|
||||||
for drink_dict in paid_consumption.values("drink_id").annotate(amount=Sum("amount")):
|
|
||||||
name = id_to_name[drink_dict["drink_id"]]
|
|
||||||
amount = drink_dict["amount"]
|
|
||||||
drink_list.append(f"{amount}x {name}")
|
|
||||||
print(f"{user.username.capitalize()}: {euro(to_pay)} ({', '.join(drink_list)})")
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-14 18:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("gaehsnitz", "0003_consumption_created_at_user_pin"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="drink",
|
||||||
|
name="year",
|
||||||
|
field=models.PositiveSmallIntegerField(default=2024),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="drink",
|
||||||
|
unique_together={("name", "year")},
|
||||||
|
),
|
||||||
|
]
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-14 19:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import gaehsnitz.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("gaehsnitz", "0004_drink_year"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="consumption",
|
||||||
|
options={"verbose_name": "Konsum", "verbose_name_plural": "Konsum"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="donation",
|
||||||
|
options={"verbose_name": "Spende", "verbose_name_plural": "Spenden"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="drink",
|
||||||
|
options={"verbose_name": "Getränk", "verbose_name_plural": "Getränke"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="payment",
|
||||||
|
options={"verbose_name": "Ausgabe", "verbose_name_plural": "Ausgaben"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="user",
|
||||||
|
options={"verbose_name": "Benutzer", "verbose_name_plural": "Benutzer"},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consumption",
|
||||||
|
name="amount",
|
||||||
|
field=models.PositiveSmallIntegerField(verbose_name="Anzahl"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consumption",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name="Gebucht am"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consumption",
|
||||||
|
name="day",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")], verbose_name="Tag"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consumption",
|
||||||
|
name="drink",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="consumption_list",
|
||||||
|
related_query_name="consumption",
|
||||||
|
to="gaehsnitz.drink",
|
||||||
|
verbose_name="Getränk",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consumption",
|
||||||
|
name="for_free",
|
||||||
|
field=models.BooleanField(default=False, verbose_name="Gratis"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consumption",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="consumption_list",
|
||||||
|
related_query_name="consumption",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Benutzer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="donation",
|
||||||
|
name="amount",
|
||||||
|
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="donation",
|
||||||
|
name="date",
|
||||||
|
field=models.DateField(verbose_name="Datum"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="donation",
|
||||||
|
name="note",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="bottle_size",
|
||||||
|
field=models.FloatField(verbose_name="Flaschengröße (l)"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="bottles_per_crate",
|
||||||
|
field=models.PositiveSmallIntegerField(verbose_name="Flaschen pro Kasten"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="crates_ordered",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
|
||||||
|
verbose_name="Kästen bestellt",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="crates_purchased",
|
||||||
|
field=models.PositiveSmallIntegerField(verbose_name="Kästen gekauft"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="crates_returned",
|
||||||
|
field=models.PositiveSmallIntegerField(verbose_name="Kästen leer zurück"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="deposit_per_crate",
|
||||||
|
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Pfand pro Kasten"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=32, verbose_name="Name"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="purchase_price_per_crate",
|
||||||
|
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Einkaufspreis pro Kasten"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="sale_price_per_bottle",
|
||||||
|
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Verkaufspreis pro Flasche"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="year",
|
||||||
|
field=models.PositiveSmallIntegerField(default=2024, verbose_name="Jahr"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="payment",
|
||||||
|
name="amount",
|
||||||
|
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="payment",
|
||||||
|
name="date",
|
||||||
|
field=models.DateField(verbose_name="Datum"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="payment",
|
||||||
|
name="purpose",
|
||||||
|
field=models.CharField(max_length=64, verbose_name="Zweck"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="pin",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=128, verbose_name="PIN"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-14 19:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import gaehsnitz.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("gaehsnitz", "0005_alter_consumption_options_alter_donation_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserPayment",
|
||||||
|
fields=[
|
||||||
|
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag")),
|
||||||
|
(
|
||||||
|
"method",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("cash", "Bar"),
|
||||||
|
("paypal", "PayPal"),
|
||||||
|
("bank", "Überweisung"),
|
||||||
|
("other", "Sonstiges"),
|
||||||
|
],
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="Methode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("note", models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Bezahlt am")),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_payments",
|
||||||
|
related_query_name="user_payment",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Benutzer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Zahlung",
|
||||||
|
"verbose_name_plural": "Zahlungen",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-14 20:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("gaehsnitz", "0006_user_payment"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="drink",
|
||||||
|
name="category",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("beer", "Bier"),
|
||||||
|
("radler", "Radler"),
|
||||||
|
("alc_free_beer", "Bier alkoholfrei"),
|
||||||
|
("soft", "Softdrink"),
|
||||||
|
("water", "Wasser"),
|
||||||
|
],
|
||||||
|
default="beer",
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="Kategorie",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-14 20:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("gaehsnitz", "0007_drink_category"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="drink",
|
||||||
|
name="category",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("beer", "Bier"),
|
||||||
|
("radler", "Radler"),
|
||||||
|
("alc_free_beer", "Bier alkoholfrei"),
|
||||||
|
("alc_free_radler", "Radler alkoholfrei"),
|
||||||
|
("soft", "Softdrink"),
|
||||||
|
("water", "Wasser"),
|
||||||
|
],
|
||||||
|
default="beer",
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="Kategorie",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+131
-26
@@ -2,6 +2,11 @@ from django.contrib.auth.hashers import check_password, make_password
|
|||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Sum, F
|
from django.db.models import Sum, F
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def current_year():
|
||||||
|
return timezone.now().year
|
||||||
|
|
||||||
|
|
||||||
class PriceField(models.DecimalField):
|
class PriceField(models.DecimalField):
|
||||||
@@ -11,7 +16,11 @@ class PriceField(models.DecimalField):
|
|||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
pin = models.CharField(max_length=128, blank=True, default="")
|
pin = models.CharField("PIN", max_length=128, blank=True, default="")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Benutzer"
|
||||||
|
verbose_name_plural = "Benutzer"
|
||||||
|
|
||||||
def set_pin(self, raw_pin):
|
def set_pin(self, raw_pin):
|
||||||
self.pin = make_password(raw_pin)
|
self.pin = make_password(raw_pin)
|
||||||
@@ -31,16 +40,31 @@ class User(AbstractUser):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def consumed_drinks_price(self):
|
def consumed_drinks_price(self):
|
||||||
query = self.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(
|
query = (
|
||||||
sum=Sum("cost")
|
self.paid_drinks.filter(drink__year=current_year())
|
||||||
|
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
|
||||||
|
.aggregate(sum=Sum("cost"))
|
||||||
)
|
)
|
||||||
return query["sum"] or 0
|
return query["sum"] or 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paid_amount(self):
|
||||||
|
query = self.user_payments.filter(created_at__year=current_year()).aggregate(sum=Sum("amount"))
|
||||||
|
return query["sum"] or 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def open_balance(self):
|
||||||
|
return self.consumed_drinks_price - self.paid_amount
|
||||||
|
|
||||||
|
|
||||||
class Donation(models.Model):
|
class Donation(models.Model):
|
||||||
date = models.DateField()
|
date = models.DateField("Datum")
|
||||||
amount = PriceField()
|
amount = PriceField("Betrag")
|
||||||
note = models.CharField(max_length=64, blank=True, default="")
|
note = models.CharField("Notiz", max_length=64, blank=True, default="")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Spende"
|
||||||
|
verbose_name_plural = "Spenden"
|
||||||
|
|
||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
@@ -49,26 +73,51 @@ class Payment(models.Model):
|
|||||||
bands = 11, "Bands"
|
bands = 11, "Bands"
|
||||||
supply_purchase = 12, "Getränke-/Essenseinkauf"
|
supply_purchase = 12, "Getränke-/Essenseinkauf"
|
||||||
|
|
||||||
purpose = models.CharField(max_length=64)
|
purpose = models.CharField("Zweck", max_length=64)
|
||||||
date = models.DateField()
|
date = models.DateField("Datum")
|
||||||
amount = PriceField()
|
amount = PriceField("Betrag")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Ausgabe"
|
||||||
|
verbose_name_plural = "Ausgaben"
|
||||||
|
|
||||||
|
|
||||||
class Drink(models.Model):
|
class Drink(models.Model):
|
||||||
name = models.CharField(max_length=32, unique=True)
|
class Category(models.TextChoices):
|
||||||
crates_ordered = models.PositiveSmallIntegerField(
|
beer = "beer", "Bier"
|
||||||
help_text="just informational to see how good we planned, not the actual consumed/paid drinks"
|
radler = "radler", "Radler"
|
||||||
|
alc_free_beer = "alc_free_beer", "Bier alkoholfrei"
|
||||||
|
alc_free_radler = "alc_free_radler", "Radler alkoholfrei"
|
||||||
|
soft = "soft", "Softdrink"
|
||||||
|
water = "water", "Wasser"
|
||||||
|
|
||||||
|
name = models.CharField("Name", max_length=32)
|
||||||
|
year = models.PositiveSmallIntegerField("Jahr", default=2024)
|
||||||
|
category = models.CharField(
|
||||||
|
"Kategorie",
|
||||||
|
max_length=16,
|
||||||
|
choices=Category.choices,
|
||||||
|
default=Category.beer,
|
||||||
)
|
)
|
||||||
crates_purchased = models.PositiveSmallIntegerField()
|
crates_ordered = models.PositiveSmallIntegerField(
|
||||||
crates_returned = models.PositiveSmallIntegerField()
|
"Kästen bestellt",
|
||||||
purchase_price_per_crate = PriceField()
|
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
|
||||||
deposit_per_crate = PriceField()
|
)
|
||||||
bottles_per_crate = models.PositiveSmallIntegerField()
|
crates_purchased = models.PositiveSmallIntegerField("Kästen gekauft")
|
||||||
bottle_size = models.FloatField()
|
crates_returned = models.PositiveSmallIntegerField("Kästen leer zurück")
|
||||||
sale_price_per_bottle = PriceField()
|
purchase_price_per_crate = PriceField("Einkaufspreis pro Kasten")
|
||||||
|
deposit_per_crate = PriceField("Pfand pro Kasten")
|
||||||
|
bottles_per_crate = models.PositiveSmallIntegerField("Flaschen pro Kasten")
|
||||||
|
bottle_size = models.FloatField("Flaschengröße (l)")
|
||||||
|
sale_price_per_bottle = PriceField("Verkaufspreis pro Flasche")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (("name", "year"),)
|
||||||
|
verbose_name = "Getränk"
|
||||||
|
verbose_name_plural = "Getränke"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return f"{self.name} {self.year}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bottles_total(self):
|
def bottles_total(self):
|
||||||
@@ -132,15 +181,71 @@ class Drink(models.Model):
|
|||||||
def balance(self):
|
def balance(self):
|
||||||
return self.sale_price_total - self.sales_purchase_value - self.giveaway_purchase_value
|
return self.sale_price_total - self.sales_purchase_value - self.giveaway_purchase_value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crates_full_returned(self):
|
||||||
|
return self.crates_ordered - self.crates_purchased
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crates_remaining(self):
|
||||||
|
return self.crates_purchased - self.crates_returned
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bottles_consumed(self):
|
||||||
|
return self.bottles_sold + self.bottles_given_away
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bottles_remaining(self):
|
||||||
|
return self.bottles_total - self.bottles_consumed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_purchase_value(self):
|
||||||
|
return self.bottles_remaining * self.purchase_price_per_bottle
|
||||||
|
|
||||||
|
|
||||||
|
class UserPayment(models.Model):
|
||||||
|
class Method(models.TextChoices):
|
||||||
|
cash = "cash", "Bar"
|
||||||
|
paypal = "paypal", "PayPal"
|
||||||
|
bank = "bank", "Überweisung"
|
||||||
|
other = "other", "Sonstiges"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
verbose_name="Benutzer",
|
||||||
|
to=User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="user_payments",
|
||||||
|
related_query_name="user_payment",
|
||||||
|
)
|
||||||
|
amount = PriceField("Betrag")
|
||||||
|
method = models.CharField("Methode", max_length=16, choices=Method.choices)
|
||||||
|
note = models.CharField("Notiz", max_length=64, blank=True, default="")
|
||||||
|
created_at = models.DateTimeField("Bezahlt am", auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Zahlung"
|
||||||
|
verbose_name_plural = "Zahlungen"
|
||||||
|
|
||||||
|
|
||||||
class Consumption(models.Model):
|
class Consumption(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
to=User, on_delete=models.CASCADE, related_name="consumption_list", related_query_name="consumption"
|
verbose_name="Benutzer",
|
||||||
|
to=User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="consumption_list",
|
||||||
|
related_query_name="consumption",
|
||||||
)
|
)
|
||||||
drink = models.ForeignKey(
|
drink = models.ForeignKey(
|
||||||
to=Drink, on_delete=models.CASCADE, related_name="consumption_list", related_query_name="consumption"
|
verbose_name="Getränk",
|
||||||
|
to=Drink,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="consumption_list",
|
||||||
|
related_query_name="consumption",
|
||||||
)
|
)
|
||||||
amount = models.PositiveSmallIntegerField()
|
amount = models.PositiveSmallIntegerField("Anzahl")
|
||||||
day = models.PositiveSmallIntegerField(choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
|
day = models.PositiveSmallIntegerField("Tag", choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")])
|
||||||
for_free = models.BooleanField(default=False)
|
for_free = models.BooleanField("Gratis", default=False)
|
||||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
created_at = models.DateTimeField("Gebucht am", auto_now_add=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Konsum"
|
||||||
|
verbose_name_plural = "Konsum"
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ label {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
background-color: rgba(80, 40, 10, 0.4);
|
background-color: rgba(80, 40, 10, 0.4);
|
||||||
color: #EEEEEE;
|
color: #EEEEEE;
|
||||||
border: 2px solid #885522;
|
border: 2px solid #885522;
|
||||||
@@ -96,9 +98,24 @@ input[type="text"] {
|
|||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 52px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"]:focus {
|
select {
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path fill='%23EE9933' d='M0 0l6 8 6-8z'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 14px center;
|
||||||
|
background-size: 12px 8px;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #EE9933;
|
border-color: #EE9933;
|
||||||
}
|
}
|
||||||
@@ -186,14 +203,58 @@ section {
|
|||||||
|
|
||||||
.drink-btn {
|
.drink-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 3 / 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
padding: 12px 8px;
|
padding: 8px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
color: #161616;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drink-btn-beer {
|
||||||
|
background: linear-gradient(180deg, #f0c878 0%, #b8731a 100%);
|
||||||
|
}
|
||||||
|
.drink-btn-beer:hover, .drink-btn-beer:focus {
|
||||||
|
background: linear-gradient(180deg, #f7d699 0%, #cc8520 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drink-btn-radler {
|
||||||
|
background: linear-gradient(180deg, #f7e36b 0%, #d8a02a 100%);
|
||||||
|
}
|
||||||
|
.drink-btn-radler:hover, .drink-btn-radler:focus {
|
||||||
|
background: linear-gradient(180deg, #fff08c 0%, #e8b034 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drink-btn-alc_free_beer {
|
||||||
|
background: linear-gradient(180deg, #f0c878 0%, #5e8bbf 100%);
|
||||||
|
}
|
||||||
|
.drink-btn-alc_free_beer:hover, .drink-btn-alc_free_beer:focus {
|
||||||
|
background: linear-gradient(180deg, #f7d699 0%, #7aa3d1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drink-btn-alc_free_radler {
|
||||||
|
background: linear-gradient(180deg, #f7e36b 0%, #5e8bbf 100%);
|
||||||
|
}
|
||||||
|
.drink-btn-alc_free_radler:hover, .drink-btn-alc_free_radler:focus {
|
||||||
|
background: linear-gradient(180deg, #fff08c 0%, #7aa3d1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drink-btn-soft {
|
||||||
|
background: linear-gradient(180deg, #f0a35e 0%, #a85a22 100%);
|
||||||
|
}
|
||||||
|
.drink-btn-soft:hover, .drink-btn-soft:focus {
|
||||||
|
background: linear-gradient(180deg, #f7b878 0%, #c06a2c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drink-btn-water {
|
||||||
|
background: linear-gradient(180deg, #d5e8f4 0%, #95c2dc 100%);
|
||||||
|
}
|
||||||
|
.drink-btn-water:hover, .drink-btn-water:focus {
|
||||||
|
background: linear-gradient(180deg, #e4f0f8 0%, #aad0e6 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drink-plus {
|
.drink-plus {
|
||||||
@@ -211,7 +272,7 @@ section {
|
|||||||
.drink-price {
|
.drink-price {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
opacity: 0.8;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history {
|
.history {
|
||||||
@@ -377,3 +438,49 @@ section {
|
|||||||
background-color: rgba(80, 40, 10, 0.4);
|
background-color: rgba(80, 40, 10, 0.4);
|
||||||
color: #FFCC77;
|
color: #FFCC77;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background-color: rgba(80, 40, 10, 0.4);
|
||||||
|
border: 2px solid #885522;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #EE9933;
|
||||||
|
box-shadow: 0 0 12px #CC6611;
|
||||||
|
transition: width 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #161616;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-row {
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn-paypal {
|
||||||
|
background-color: #003087;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn-paypal:hover, .link-btn-paypal:focus {
|
||||||
|
background-color: #0070BA;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|||||||
+207
-23
@@ -2,10 +2,11 @@ from datetime import datetime
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.contrib.auth import authenticate, login, logout
|
from django.contrib.auth import authenticate, login, logout
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.models import F, Sum
|
from django.db.models import Case, F, IntegerField, Sum, Value, When
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -13,14 +14,16 @@ from django.utils import timezone
|
|||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from gaehsnitz.models import Consumption, Drink
|
from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
BERLIN = ZoneInfo("Europe/Berlin")
|
BERLIN = ZoneInfo("Europe/Berlin")
|
||||||
BOOKING_START = datetime(2026, 6, 11, 0, 0, 0, tzinfo=BERLIN)
|
# TEST WINDOW (2026-05-15 – 2026-05-31): enabled early for pre-festival testing.
|
||||||
BOOKING_END = datetime(2026, 6, 14, 23, 59, 59, tzinfo=BERLIN)
|
# Original festival phase: BOOKING_START = 2026-06-11, BOOKING_END = 2026-06-14.
|
||||||
READONLY_END = datetime(2026, 6, 21, 23, 59, 59, tzinfo=BERLIN)
|
# Switch back to original dates before the festival.
|
||||||
|
BOOKING_START = datetime(2026, 5, 15, 0, 0, 0, tzinfo=BERLIN)
|
||||||
|
BOOKING_END = datetime(2026, 5, 31, 23, 59, 59, tzinfo=BERLIN)
|
||||||
|
|
||||||
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
|
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
|
||||||
|
|
||||||
@@ -37,17 +40,15 @@ def _phase():
|
|||||||
return "before"
|
return "before"
|
||||||
if now <= BOOKING_END:
|
if now <= BOOKING_END:
|
||||||
return "booking"
|
return "booking"
|
||||||
if now <= READONLY_END:
|
|
||||||
return "readonly"
|
|
||||||
return "closed"
|
return "closed"
|
||||||
|
|
||||||
|
|
||||||
def _require_open(request):
|
def _require_open(request):
|
||||||
"""Raise 404 once tool is fully closed. Return current phase otherwise."""
|
"""Redirect to closed page when tool is not in booking phase."""
|
||||||
phase = _phase()
|
phase = _phase()
|
||||||
if phase == "closed":
|
if phase in ("before", "closed"):
|
||||||
raise Http404
|
return HttpResponseRedirect(reverse("suff:closed")), None
|
||||||
return phase
|
return None, phase
|
||||||
|
|
||||||
|
|
||||||
def _current_festival_day():
|
def _current_festival_day():
|
||||||
@@ -60,23 +61,32 @@ def _normalize_name(raw):
|
|||||||
|
|
||||||
|
|
||||||
def _tab_context(user):
|
def _tab_context(user):
|
||||||
consumption = user.consumption_list.select_related("drink").order_by("-created_at", "-id")
|
year = current_year()
|
||||||
paid = (
|
consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id")
|
||||||
user.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(total=Sum("cost"))[
|
total = (
|
||||||
"total"
|
user.paid_drinks.filter(drink__year=year)
|
||||||
]
|
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
|
||||||
|
.aggregate(total=Sum("cost"))["total"]
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
payments = user.user_payments.filter(created_at__year=year).order_by("-created_at")
|
||||||
|
paid = payments.aggregate(sum=Sum("amount"))["sum"] or 0
|
||||||
return {
|
return {
|
||||||
"tab_user": user,
|
"tab_user": user,
|
||||||
"consumption_list": consumption,
|
"consumption_list": consumption,
|
||||||
"total": paid,
|
"total": total,
|
||||||
|
"paid": paid,
|
||||||
|
"open_balance": total - paid,
|
||||||
|
"user_payments": payments,
|
||||||
|
"payment_methods": UserPayment.Method.choices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def name_view(request):
|
def name_view(request):
|
||||||
phase = _require_open(request)
|
redirect, phase = _require_open(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return HttpResponseRedirect(reverse("suff:me"))
|
return HttpResponseRedirect(reverse("suff:me"))
|
||||||
|
|
||||||
@@ -113,7 +123,9 @@ def name_view(request):
|
|||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def pin_view(request):
|
def pin_view(request):
|
||||||
phase = _require_open(request)
|
redirect, phase = _require_open(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
username = request.session.get("pending_username")
|
username = request.session.get("pending_username")
|
||||||
mode = request.session.get("pending_mode")
|
mode = request.session.get("pending_mode")
|
||||||
if not username or mode not in ("create", "login"):
|
if not username or mode not in ("create", "login"):
|
||||||
@@ -164,8 +176,27 @@ def pin_view(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def me_view(request):
|
def me_view(request):
|
||||||
phase = _require_open(request)
|
redirect, phase = _require_open(request)
|
||||||
drinks = Drink.objects.order_by("name") if phase == "booking" else Drink.objects.none()
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
drinks = (
|
||||||
|
Drink.objects.filter(year=current_year())
|
||||||
|
.annotate(
|
||||||
|
category_order=Case(
|
||||||
|
When(category="beer", then=Value(0)),
|
||||||
|
When(category="alc_free_beer", then=Value(1)),
|
||||||
|
When(category="radler", then=Value(2)),
|
||||||
|
When(category="alc_free_radler", then=Value(3)),
|
||||||
|
When(category="soft", then=Value(4)),
|
||||||
|
When(category="water", then=Value(5)),
|
||||||
|
default=Value(99),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("category_order", "name")
|
||||||
|
if phase == "booking"
|
||||||
|
else Drink.objects.none()
|
||||||
|
)
|
||||||
booked_drink = None
|
booked_drink = None
|
||||||
booked_id = request.GET.get("booked")
|
booked_id = request.GET.get("booked")
|
||||||
if booked_id:
|
if booked_id:
|
||||||
@@ -180,6 +211,7 @@ def me_view(request):
|
|||||||
"drinks": drinks,
|
"drinks": drinks,
|
||||||
"current_day": _current_festival_day(),
|
"current_day": _current_festival_day(),
|
||||||
"booked_drink": booked_drink,
|
"booked_drink": booked_drink,
|
||||||
|
"paid_toast": request.GET.get("paid") == "1",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return render(request, "suff/me.html", context)
|
return render(request, "suff/me.html", context)
|
||||||
@@ -188,7 +220,9 @@ def me_view(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def book_view(request):
|
def book_view(request):
|
||||||
phase = _require_open(request)
|
redirect, phase = _require_open(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
if phase != "booking":
|
if phase != "booking":
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
@@ -208,8 +242,158 @@ def book_view(request):
|
|||||||
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
|
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def pay_view(request):
|
||||||
|
redirect, phase = _require_open(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
raw_amount = (request.POST.get("amount") or "").replace(",", ".").strip()
|
||||||
|
method = request.POST.get("method") or ""
|
||||||
|
note = (request.POST.get("note") or "").strip()[:64]
|
||||||
|
|
||||||
|
valid_methods = {m for m, _ in UserPayment.Method.choices}
|
||||||
|
try:
|
||||||
|
amount = float(raw_amount)
|
||||||
|
except ValueError:
|
||||||
|
amount = 0.0
|
||||||
|
|
||||||
|
if method not in valid_methods:
|
||||||
|
error = "Bitte eine Zahlungsmethode wählen."
|
||||||
|
elif amount <= 0:
|
||||||
|
error = "Bitte einen Betrag größer 0 eingeben."
|
||||||
|
else:
|
||||||
|
UserPayment.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
amount=amount,
|
||||||
|
method=method,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
|
||||||
|
|
||||||
|
context = _tab_context(request.user)
|
||||||
|
context.update({"phase": phase, "error": error})
|
||||||
|
return render(request, "suff/pay.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def dashboard_view(request):
|
||||||
|
year = current_year()
|
||||||
|
|
||||||
|
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||||||
|
total_payments = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||||||
|
total_balance = total_donations - total_payments
|
||||||
|
total_pct = int(round((total_donations / total_payments) * 100)) if total_payments else 0
|
||||||
|
total_pct_capped = min(total_pct, 100)
|
||||||
|
|
||||||
|
drinks = list(Drink.objects.filter(year=year).order_by("name"))
|
||||||
|
drink_rows = [
|
||||||
|
{
|
||||||
|
"name": d.name,
|
||||||
|
"sold": d.bottles_sold,
|
||||||
|
"total": d.bottles_total,
|
||||||
|
"balance": d.balance,
|
||||||
|
}
|
||||||
|
for d in drinks
|
||||||
|
]
|
||||||
|
sales_revenue = sum((d.sale_price_total for d in drinks), 0)
|
||||||
|
purchase_cost = sum((d.purchase_price_total for d in drinks), 0)
|
||||||
|
drinks_profit = sum((d.balance for d in drinks), 0)
|
||||||
|
refinance_pct = int(round((sales_revenue / purchase_cost) * 100)) if purchase_cost else 0
|
||||||
|
refinance_pct_capped = min(refinance_pct, 100)
|
||||||
|
|
||||||
|
user_rows = []
|
||||||
|
for user in User.objects.all():
|
||||||
|
consumed = user.consumed_drinks_price
|
||||||
|
if not consumed:
|
||||||
|
continue
|
||||||
|
paid = user.paid_amount
|
||||||
|
user_rows.append(
|
||||||
|
{
|
||||||
|
"username": user.username,
|
||||||
|
"consumed": consumed,
|
||||||
|
"paid": paid,
|
||||||
|
"open": consumed - paid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user_rows.sort(key=lambda r: r["open"], reverse=True)
|
||||||
|
|
||||||
|
top_spender = None
|
||||||
|
if user_rows:
|
||||||
|
top = max(user_rows, key=lambda r: r["consumed"])
|
||||||
|
top_spender = {"username": top["username"], "total": top["consumed"]}
|
||||||
|
|
||||||
|
drink_amounts = (
|
||||||
|
Consumption.objects.filter(drink__year=year, for_free=False)
|
||||||
|
.values("drink__name")
|
||||||
|
.annotate(amount=Sum("amount"))
|
||||||
|
.order_by("-amount")
|
||||||
|
)
|
||||||
|
top_drink = None
|
||||||
|
if drink_amounts:
|
||||||
|
top_drink = {"name": drink_amounts[0]["drink__name"], "amount": drink_amounts[0]["amount"]}
|
||||||
|
|
||||||
|
day_amounts = (
|
||||||
|
Consumption.objects.filter(drink__year=year, for_free=False)
|
||||||
|
.values("day")
|
||||||
|
.annotate(amount=Sum("amount"))
|
||||||
|
.order_by("-amount")
|
||||||
|
)
|
||||||
|
busiest_day = None
|
||||||
|
if day_amounts:
|
||||||
|
d = day_amounts[0]
|
||||||
|
busiest_day = {"label": DAY_LABELS.get(d["day"], "?"), "amount": d["amount"]}
|
||||||
|
|
||||||
|
top_per_day = []
|
||||||
|
for day_num, label in DAY_LABELS.items():
|
||||||
|
rows = (
|
||||||
|
Consumption.objects.filter(drink__year=year, day=day_num, for_free=False)
|
||||||
|
.values("user__username")
|
||||||
|
.annotate(amount=Sum("amount"))
|
||||||
|
.order_by("-amount")
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
r = rows[0]
|
||||||
|
top_per_day.append({"label": label, "username": r["user__username"], "amount": r["amount"]})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"year": year,
|
||||||
|
"total_donations": total_donations,
|
||||||
|
"total_payments": total_payments,
|
||||||
|
"total_balance": total_balance,
|
||||||
|
"total_pct": total_pct,
|
||||||
|
"total_pct_capped": total_pct_capped,
|
||||||
|
"drink_rows": drink_rows,
|
||||||
|
"sales_revenue": sales_revenue,
|
||||||
|
"purchase_cost": purchase_cost,
|
||||||
|
"drinks_profit": drinks_profit,
|
||||||
|
"refinance_pct": refinance_pct,
|
||||||
|
"refinance_pct_capped": refinance_pct_capped,
|
||||||
|
"user_rows": user_rows,
|
||||||
|
"top_spender": top_spender,
|
||||||
|
"top_drink": top_drink,
|
||||||
|
"busiest_day": busiest_day,
|
||||||
|
"top_per_day": top_per_day,
|
||||||
|
}
|
||||||
|
return render(request, "suff/dashboard.html", context)
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def logout_view(request):
|
def logout_view(request):
|
||||||
_require_open(request)
|
|
||||||
logout(request)
|
logout(request)
|
||||||
return HttpResponseRedirect(reverse("suff:name"))
|
return HttpResponseRedirect(reverse("suff:name"))
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def closed_view(request):
|
||||||
|
phase = _phase()
|
||||||
|
if phase == "booking":
|
||||||
|
return HttpResponseRedirect(reverse("suff:name"))
|
||||||
|
return render(request, "suff/closed.html", {"phase": phase, "booking_start": BOOKING_START})
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ from django.urls import path
|
|||||||
|
|
||||||
from gaehsnitz.suff import (
|
from gaehsnitz.suff import (
|
||||||
book_view,
|
book_view,
|
||||||
|
closed_view,
|
||||||
|
dashboard_view,
|
||||||
logout_view,
|
logout_view,
|
||||||
me_view,
|
me_view,
|
||||||
name_view,
|
name_view,
|
||||||
|
pay_view,
|
||||||
pin_view,
|
pin_view,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,8 +15,11 @@ app_name = "suff"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", name_view, name="name"),
|
path("", name_view, name="name"),
|
||||||
|
path("closed/", closed_view, name="closed"),
|
||||||
path("pin/", pin_view, name="pin"),
|
path("pin/", pin_view, name="pin"),
|
||||||
path("me/", me_view, name="me"),
|
path("me/", me_view, name="me"),
|
||||||
path("book/", book_view, name="book"),
|
path("book/", book_view, name="book"),
|
||||||
|
path("pay/", pay_view, name="pay"),
|
||||||
|
path("dashboard/", dashboard_view, name="dashboard"),
|
||||||
path("logout/", logout_view, name="logout"),
|
path("logout/", logout_view, name="logout"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "suff/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if phase == "before" %}
|
||||||
|
<p>Hier könnt ihr während des Festivals eure Getränke selbst buchen und den Überblick über eure Rechnung behalten — die digitale Strichliste.</p>
|
||||||
|
<p>Das Tool startet am <strong>{{ booking_start|date:"d.m.Y" }}</strong> — schaut dann nochmal rein!</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Das Festival ist vorbei. Das Tool ist jetzt deaktiviert.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "suff/base.html" %}
|
||||||
|
{% load money %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Dashboard {{ year }}</h2>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Refinanzierung gesamt</h3>
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-bar" style="width: {{ total_pct_capped }}%;"></div>
|
||||||
|
<span class="progress-label">{{ total_pct }}%</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted-left">
|
||||||
|
Spenden {{ total_donations|euro }} / Ausgaben {{ total_payments|euro }}
|
||||||
|
</p>
|
||||||
|
<p class="muted-left">
|
||||||
|
Bilanz: <b>{{ total_balance|euro }}</b>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Refinanzierung Getränke</h3>
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-bar" style="width: {{ refinance_pct_capped }}%;"></div>
|
||||||
|
<span class="progress-label">{{ refinance_pct }}%</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted-left">
|
||||||
|
Verkaufserlös {{ sales_revenue|euro }} / Einkaufspreis {{ purchase_cost|euro }}
|
||||||
|
</p>
|
||||||
|
<p class="muted-left">
|
||||||
|
Aktueller Gewinn: <b>{{ drinks_profit|euro }}</b>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Getränke</h3>
|
||||||
|
<ul class="history">
|
||||||
|
{% for d in drink_rows %}
|
||||||
|
<li class="dash-row">
|
||||||
|
<span class="hist-what"><b>{{ d.name }}</b></span>
|
||||||
|
<span class="hist-when">{{ d.sold }}/{{ d.total }} verkauft</span>
|
||||||
|
<span class="hist-price">{{ d.balance|euro }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Offene Beträge</h3>
|
||||||
|
{% if user_rows %}
|
||||||
|
<ul class="history">
|
||||||
|
{% for u in user_rows %}
|
||||||
|
<li>
|
||||||
|
<span class="hist-when">{{ u.username }}</span>
|
||||||
|
<span class="hist-what">{{ u.consumed|euro }} − {{ u.paid|euro }}</span>
|
||||||
|
<span class="hist-price">{{ u.open|euro }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-left">Niemand hat etwas konsumiert.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Fun Facts</h3>
|
||||||
|
<ul class="history">
|
||||||
|
{% if top_spender %}
|
||||||
|
<li>
|
||||||
|
<span class="hist-when">Top-Zecher</span>
|
||||||
|
<span class="hist-what">{{ top_spender.username }}</span>
|
||||||
|
<span class="hist-price">{{ top_spender.total|euro }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if top_drink %}
|
||||||
|
<li>
|
||||||
|
<span class="hist-when">Top-Getränk</span>
|
||||||
|
<span class="hist-what">{{ top_drink.name }}</span>
|
||||||
|
<span class="hist-price">{{ top_drink.amount }}x</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if busiest_day %}
|
||||||
|
<li>
|
||||||
|
<span class="hist-when">Härtester Tag</span>
|
||||||
|
<span class="hist-what">{{ busiest_day.label }}</span>
|
||||||
|
<span class="hist-price">{{ busiest_day.amount }} Flaschen</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for f in top_per_day %}
|
||||||
|
<li>
|
||||||
|
<span class="hist-when">{{ f.label }}</span>
|
||||||
|
<span class="hist-what">{{ f.username }}</span>
|
||||||
|
<span class="hist-price">{{ f.amount }} Flaschen</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="logout-form">
|
||||||
|
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,21 +3,33 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Hallo {{ tab_user.username }}</h2>
|
<h2>Hallo {{ tab_user.username }}</h2>
|
||||||
|
|
||||||
{% if phase == "readonly" %}
|
|
||||||
<p><i>Festival vorbei. Buchungen geschlossen, nur noch Anzeige.</i></p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if booked_drink %}
|
{% if booked_drink %}
|
||||||
<div class="toast" role="status">
|
<div class="toast" role="status">
|
||||||
Gebucht: +1 {{ booked_drink.name }}
|
Gebucht: +1 {{ booked_drink.name }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if paid_toast %}
|
||||||
|
<div class="toast" role="status">
|
||||||
|
Zahlung gespeichert. Danke!
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="total-box">
|
<section class="total-box">
|
||||||
<span class="total-label">Deine Rechnung</span>
|
<span class="total-label">Deine Rechnung</span>
|
||||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||||
|
{% if paid %}
|
||||||
|
<span class="total-label">Bezahlt</span>
|
||||||
|
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||||
|
<span class="total-label">Offen</span>
|
||||||
|
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if open_balance > 0 %}
|
||||||
|
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Bezahlen</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if phase == "booking" %}
|
{% if phase == "booking" %}
|
||||||
<section>
|
<section>
|
||||||
<h3>Neues Getränk buchen</h3>
|
<h3>Neues Getränk buchen</h3>
|
||||||
@@ -26,7 +38,7 @@
|
|||||||
<form method="post" action="{% url 'suff:book' %}">
|
<form method="post" action="{% url 'suff:book' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
|
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
|
||||||
<button type="submit" class="drink-btn">
|
<button type="submit" class="drink-btn drink-btn-{{ drink.category }}">
|
||||||
<span class="drink-plus">+1</span>
|
<span class="drink-plus">+1</span>
|
||||||
<span class="drink-name">{{ drink.name }}</span>
|
<span class="drink-name">{{ drink.name }}</span>
|
||||||
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
||||||
@@ -66,6 +78,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if request.user.is_staff %}
|
||||||
|
<div class="link-row">
|
||||||
|
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:logout' %}" class="logout-form">
|
<form method="post" action="{% url 'suff:logout' %}" class="logout-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn-secondary">Logout</button>
|
<button type="submit" class="btn-secondary">Logout</button>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends "suff/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Bezahlen</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Bitte zahle deinen offenen Betrag mit deiner bevorzugten Methode
|
||||||
|
(z. B. Bar an der Kasse oder PayPal an Flo) und trage
|
||||||
|
den bezahlten Betrag anschließend hier ein.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="link-row">
|
||||||
|
<a href="https://www.paypal.com/paypalme/lotharwiener" target="_blank" rel="noopener noreferrer" class="link-btn link-btn-paypal">
|
||||||
|
PayPal öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="total-box">
|
||||||
|
<span class="total-label">Deine Rechnung</span>
|
||||||
|
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||||
|
{% if paid %}
|
||||||
|
<span class="total-label">Bezahlt</span>
|
||||||
|
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="total-label">Offen</span>
|
||||||
|
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'suff:pay' %}" class="pay-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label>
|
||||||
|
Betrag (€)
|
||||||
|
<input type="number" name="amount" step="0.01" min="0.01"
|
||||||
|
value="{{ open_balance|floatformat:2 }}" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Methode
|
||||||
|
<select name="method" required>
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{% for value, label in payment_methods %}
|
||||||
|
<option value="{{ value }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Notiz (optional)
|
||||||
|
<input type="text" name="note" maxlength="64" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary">Zahlung eintragen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if user_payments %}
|
||||||
|
<section>
|
||||||
|
<h3>Bisherige Zahlungen</h3>
|
||||||
|
<ul class="history">
|
||||||
|
{% for p in user_payments %}
|
||||||
|
<li>
|
||||||
|
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
|
||||||
|
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %}</span>
|
||||||
|
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="logout-form">
|
||||||
|
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -94,7 +94,8 @@ TEMPLATES = [
|
|||||||
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
|
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Berlin"
|
TIME_ZONE = "Europe/Berlin"
|
||||||
USE_I18N = False
|
LANGUAGE_CODE = "de"
|
||||||
|
USE_I18N = True
|
||||||
USE_L10N = False
|
USE_L10N = False
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ dependencies = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.12",
|
"ruff==0.15.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
Reference in New Issue
Block a user