Add year-scoped drink data, UserPayment model, German verbose names

Drink gets a year field (default 2024 for legacy rows), with name+year
unique together so each festival can have its own price/crate config
without overwriting the previous year. User balance properties
(consumed_drinks_price, paid_amount, open_balance) and the new
current_year() helper all filter by current year so year-over-year data
stays separated.

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:15:57 +02:00
parent 2d5ec8fc6d
commit 4c9d041254
5 changed files with 362 additions and 26 deletions
+26
View File
@@ -0,0 +1,26 @@
# Generated by Django 6.0.5 on 2026-05-14 18:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0003_consumption_created_at_user_pin"),
]
operations = [
migrations.AddField(
model_name="drink",
name="year",
field=models.PositiveSmallIntegerField(default=2024),
),
migrations.AlterField(
model_name="drink",
name="name",
field=models.CharField(max_length=32),
),
migrations.AlterUniqueTogether(
name="drink",
unique_together={("name", "year")},
),
]
@@ -0,0 +1,167 @@
# Generated by Django 6.0.5 on 2026-05-14 19:15
import django.db.models.deletion
import gaehsnitz.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0004_drink_year"),
]
operations = [
migrations.AlterModelOptions(
name="consumption",
options={"verbose_name": "Konsum", "verbose_name_plural": "Konsum"},
),
migrations.AlterModelOptions(
name="donation",
options={"verbose_name": "Spende", "verbose_name_plural": "Spenden"},
),
migrations.AlterModelOptions(
name="drink",
options={"verbose_name": "Getränk", "verbose_name_plural": "Getränke"},
),
migrations.AlterModelOptions(
name="payment",
options={"verbose_name": "Ausgabe", "verbose_name_plural": "Ausgaben"},
),
migrations.AlterModelOptions(
name="user",
options={"verbose_name": "Benutzer", "verbose_name_plural": "Benutzer"},
),
migrations.AlterField(
model_name="consumption",
name="amount",
field=models.PositiveSmallIntegerField(verbose_name="Anzahl"),
),
migrations.AlterField(
model_name="consumption",
name="created_at",
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name="Gebucht am"),
),
migrations.AlterField(
model_name="consumption",
name="day",
field=models.PositiveSmallIntegerField(
choices=[(1, "Do"), (2, "Fr"), (3, "Sa"), (4, "So")], verbose_name="Tag"
),
),
migrations.AlterField(
model_name="consumption",
name="drink",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
to="gaehsnitz.drink",
verbose_name="Getränk",
),
),
migrations.AlterField(
model_name="consumption",
name="for_free",
field=models.BooleanField(default=False, verbose_name="Gratis"),
),
migrations.AlterField(
model_name="consumption",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="consumption_list",
related_query_name="consumption",
to=settings.AUTH_USER_MODEL,
verbose_name="Benutzer",
),
),
migrations.AlterField(
model_name="donation",
name="amount",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
),
migrations.AlterField(
model_name="donation",
name="date",
field=models.DateField(verbose_name="Datum"),
),
migrations.AlterField(
model_name="donation",
name="note",
field=models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz"),
),
migrations.AlterField(
model_name="drink",
name="bottle_size",
field=models.FloatField(verbose_name="Flaschengröße (l)"),
),
migrations.AlterField(
model_name="drink",
name="bottles_per_crate",
field=models.PositiveSmallIntegerField(verbose_name="Flaschen pro Kasten"),
),
migrations.AlterField(
model_name="drink",
name="crates_ordered",
field=models.PositiveSmallIntegerField(
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
verbose_name="Kästen bestellt",
),
),
migrations.AlterField(
model_name="drink",
name="crates_purchased",
field=models.PositiveSmallIntegerField(verbose_name="Kästen gekauft"),
),
migrations.AlterField(
model_name="drink",
name="crates_returned",
field=models.PositiveSmallIntegerField(verbose_name="Kästen leer zurück"),
),
migrations.AlterField(
model_name="drink",
name="deposit_per_crate",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Pfand pro Kasten"),
),
migrations.AlterField(
model_name="drink",
name="name",
field=models.CharField(max_length=32, verbose_name="Name"),
),
migrations.AlterField(
model_name="drink",
name="purchase_price_per_crate",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Einkaufspreis pro Kasten"),
),
migrations.AlterField(
model_name="drink",
name="sale_price_per_bottle",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Verkaufspreis pro Flasche"),
),
migrations.AlterField(
model_name="drink",
name="year",
field=models.PositiveSmallIntegerField(default=2024, verbose_name="Jahr"),
),
migrations.AlterField(
model_name="payment",
name="amount",
field=gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag"),
),
migrations.AlterField(
model_name="payment",
name="date",
field=models.DateField(verbose_name="Datum"),
),
migrations.AlterField(
model_name="payment",
name="purpose",
field=models.CharField(max_length=64, verbose_name="Zweck"),
),
migrations.AlterField(
model_name="user",
name="pin",
field=models.CharField(blank=True, default="", max_length=128, verbose_name="PIN"),
),
]
+51
View File
@@ -0,0 +1,51 @@
# Generated by Django 6.0.5 on 2026-05-14 19:37
import django.db.models.deletion
import gaehsnitz.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gaehsnitz", "0005_alter_consumption_options_alter_donation_options_and_more"),
]
operations = [
migrations.CreateModel(
name="UserPayment",
fields=[
("id", models.SmallAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("amount", gaehsnitz.models.PriceField(decimal_places=2, max_digits=6, verbose_name="Betrag")),
(
"method",
models.CharField(
choices=[
("cash", "Bar"),
("paypal", "PayPal"),
("bank", "Überweisung"),
("other", "Sonstiges"),
],
max_length=16,
verbose_name="Methode",
),
),
("note", models.CharField(blank=True, default="", max_length=64, verbose_name="Notiz")),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Bezahlt am")),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_payments",
related_query_name="user_payment",
to=settings.AUTH_USER_MODEL,
verbose_name="Benutzer",
),
),
],
options={
"verbose_name": "Zahlung",
"verbose_name_plural": "Zahlungen",
},
),
]
+116 -25
View File
@@ -2,6 +2,11 @@ from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import Sum, F from django.db.models import Sum, F
from django.utils import timezone
def current_year():
return timezone.now().year
class PriceField(models.DecimalField): class PriceField(models.DecimalField):
@@ -11,7 +16,11 @@ class PriceField(models.DecimalField):
class User(AbstractUser): class User(AbstractUser):
pin = models.CharField(max_length=128, blank=True, default="") pin = models.CharField("PIN", max_length=128, blank=True, default="")
class Meta:
verbose_name = "Benutzer"
verbose_name_plural = "Benutzer"
def set_pin(self, raw_pin): def set_pin(self, raw_pin):
self.pin = make_password(raw_pin) self.pin = make_password(raw_pin)
@@ -31,16 +40,31 @@ class User(AbstractUser):
@property @property
def consumed_drinks_price(self): def consumed_drinks_price(self):
query = self.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate( query = (
sum=Sum("cost") self.paid_drinks.filter(drink__year=current_year())
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
.aggregate(sum=Sum("cost"))
) )
return query["sum"] or 0 return query["sum"] or 0
@property
def paid_amount(self):
query = self.user_payments.filter(created_at__year=current_year()).aggregate(sum=Sum("amount"))
return query["sum"] or 0
@property
def open_balance(self):
return self.consumed_drinks_price - self.paid_amount
class Donation(models.Model): class Donation(models.Model):
date = models.DateField() date = models.DateField("Datum")
amount = PriceField() amount = PriceField("Betrag")
note = models.CharField(max_length=64, blank=True, default="") note = models.CharField("Notiz", max_length=64, blank=True, default="")
class Meta:
verbose_name = "Spende"
verbose_name_plural = "Spenden"
class Payment(models.Model): class Payment(models.Model):
@@ -49,26 +73,37 @@ 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) name = models.CharField("Name", max_length=32)
year = models.PositiveSmallIntegerField("Jahr", default=2024)
crates_ordered = models.PositiveSmallIntegerField( crates_ordered = models.PositiveSmallIntegerField(
help_text="just informational to see how good we planned, not the actual consumed/paid drinks" "Kästen bestellt",
help_text="nur zur Info, wie gut wir geplant haben — nicht die tatsächlich konsumierten/bezahlten Flaschen",
) )
crates_purchased = models.PositiveSmallIntegerField() crates_purchased = models.PositiveSmallIntegerField("Kästen gekauft")
crates_returned = models.PositiveSmallIntegerField() crates_returned = models.PositiveSmallIntegerField("Kästen leer zurück")
purchase_price_per_crate = PriceField() purchase_price_per_crate = PriceField("Einkaufspreis pro Kasten")
deposit_per_crate = PriceField() deposit_per_crate = PriceField("Pfand pro Kasten")
bottles_per_crate = models.PositiveSmallIntegerField() bottles_per_crate = models.PositiveSmallIntegerField("Flaschen pro Kasten")
bottle_size = models.FloatField() bottle_size = models.FloatField("Flaschengröße (l)")
sale_price_per_bottle = PriceField() 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 +167,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"
+2 -1
View File
@@ -94,7 +94,8 @@ TEMPLATES = [
WSGI_APPLICATION = "gaehsnitzproject.wsgi.application" WSGI_APPLICATION = "gaehsnitzproject.wsgi.application"
TIME_ZONE = "Europe/Berlin" TIME_ZONE = "Europe/Berlin"
USE_I18N = False LANGUAGE_CODE = "de"
USE_I18N = True
USE_L10N = False USE_L10N = False
USE_TZ = True USE_TZ = True