Show year-scoped balances and breakdowns in admin

User list shows Konsumiert / Bezahlt / Offener Betrag for the current
year. User detail page adds a BILANZ section with per-drink breakdowns
(paid + free) so a quick look at the user page replaces what
user_stats used to print.

Drink list shows crates_purchased, bottles_sold, bottles_remaining,
purchase_price_total, and balance, with a year filter. Detail page
groups all derived values into German sections (Kästen, Flaschen,
Menge, Einkauf, Pfand, Verkauf), replacing the drink_stats command.

UserPayment admin gets a custom YearFilter (created_at__year is not
admin-filterable directly). Donation/Payment get date filters and
search.

Group admin is unregistered (unused in this project).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:16:04 +02:00
parent 4c9d041254
commit b9c62babf1
+177 -15
View File
@@ -3,13 +3,18 @@ from django.contrib import admin, messages
from django.contrib.admin.utils import unquote from django.contrib.admin.utils import unquote
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group
admin.site.unregister(Group)
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import path, reverse from django.urls import path, reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from gaehsnitz.models import Donation, Payment, Drink, Consumption from django.db.models import Sum
from gaehsnitz.models import Donation, Payment, Drink, Consumption, UserPayment, current_year
from gaehsnitz.templatetags.money import euro from gaehsnitz.templatetags.money import euro
User = get_user_model() User = get_user_model()
@@ -20,6 +25,12 @@ class ConsumptionInline(admin.TabularInline):
extra = 0 extra = 0
class UserPaymentInline(admin.TabularInline):
model = UserPayment
extra = 0
readonly_fields = ("created_at",)
class SetPinForm(forms.Form): class SetPinForm(forms.Form):
pin = forms.CharField( pin = forms.CharField(
label="Neue PIN (3 Ziffern)", label="Neue PIN (3 Ziffern)",
@@ -36,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>")