From b9c62babf136f53568a541c9dfea9af5b4a31ace Mon Sep 17 00:00:00 2001 From: Flo Ha Date: Thu, 14 May 2026 22:16:04 +0200 Subject: [PATCH] Show year-scoped balances and breakdowns in admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gaehsnitz/admin.py | 192 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 177 insertions(+), 15 deletions(-) diff --git a/gaehsnitz/admin.py b/gaehsnitz/admin.py index fc02e14..c77d3b4 100644 --- a/gaehsnitz/admin.py +++ b/gaehsnitz/admin.py @@ -3,13 +3,18 @@ from django.contrib import admin, messages from django.contrib.admin.utils import unquote from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group + +admin.site.unregister(Group) from django.http import Http404, HttpResponseRedirect from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.html import format_html from django.utils.safestring import mark_safe -from 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 User = get_user_model() @@ -20,6 +25,12 @@ class ConsumptionInline(admin.TabularInline): extra = 0 +class UserPaymentInline(admin.TabularInline): + model = UserPayment + extra = 0 + readonly_fields = ("created_at",) + + class SetPinForm(forms.Form): pin = forms.CharField( label="Neue PIN (3 Ziffern)", @@ -36,7 +47,7 @@ class SetPinForm(forms.Form): @admin.register(User) class CustomUserAdmin(UserAdmin): - list_display = ("username", "consumed_drinks_price") + list_display = ("username", "consumed_drinks_price", "paid_amount", "open_balance") ordering = ("username",) list_filter = [] 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") - inlines = (ConsumptionInline,) + readonly_fields = ( + "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): return euro(user.consumed_drinks_price) + @admin.display(description="Bezahlt") + def paid_amount(self, user: User): + return euro(user.paid_amount) + + @admin.display(description="Offener Betrag") + def open_balance(self, user: User): + return euro(user.open_balance) + + def _breakdown(self, user: User, for_free: bool): + if user.pk is None: + return "-" + rows = ( + user.consumption_list.filter(for_free=for_free, drink__year=current_year()) + .values("drink__name") + .annotate(amount=Sum("amount")) + .order_by("drink__name") + ) + if not rows: + return "-" + return ", ".join(f"{r['amount']}x {r['drink__name']}" for r in rows) + + @admin.display(description="Bezahlt") + def drinks_breakdown(self, user: User): + return self._breakdown(user, for_free=False) + + @admin.display(description="Gratis") + def free_drinks_breakdown(self, user: User): + return self._breakdown(user, for_free=True) + @admin.display(description="PIN") def pin_status(self, user: User): status = "gesetzt" if user.pin else "nicht gesetzt" @@ -104,10 +163,39 @@ class CustomUserAdmin(UserAdmin): return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context) +class YearFilter(admin.SimpleListFilter): + title = "Jahr" + parameter_name = "year" + field_name = "created_at" + + def lookups(self, request, model_admin): + years = model_admin.model.objects.dates(self.field_name, "year", order="DESC") + return [(y.year, str(y.year)) for y in years] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(**{f"{self.field_name}__year": self.value()}) + return queryset + + +@admin.register(UserPayment) +class UserPaymentAdmin(admin.ModelAdmin): + list_display = ("created_at", "user", "amount", "method", "note") + list_filter = ("method", YearFilter) + ordering = ("-created_at",) + search_fields = ("user__username", "note") + + @admin.display(ordering="amount") + def amount(self, payment: UserPayment): + return euro(payment.amount) + + @admin.register(Donation) class DonationAdmin(admin.ModelAdmin): list_display = ("date", "amount", "note") + list_filter = ("date",) ordering = ("-date",) + search_fields = ("note",) @admin.display(ordering="amount") def amount(self, donation: Donation): @@ -117,7 +205,9 @@ class DonationAdmin(admin.ModelAdmin): @admin.register(Payment) class PaymentAdmin(admin.ModelAdmin): list_display = ("date", "purpose", "amount") + list_filter = ("date",) ordering = ("-date",) + search_fields = ("purpose",) @admin.display(ordering="amount") def amount(self, payment: Payment): @@ -126,31 +216,46 @@ class PaymentAdmin(admin.ModelAdmin): @admin.register(Drink) 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 = ( - (None, {"fields": ("name",)}), + (None, {"fields": ("name", "year")}), ( - "crates", + "Kästen", { "fields": ( "crates_ordered", "crates_purchased", + "crates_full_returned", "crates_returned", + "crates_remaining", ) }, ), ( - "bottles", + "Flaschen", { "fields": ( "bottles_per_crate", "bottles_total", "bottles_returned", + "bottles_sold", + "bottles_given_away", + "bottles_consumed", + "bottles_remaining", ) }, ), ( - "amount", + "Menge", { "fields": ( "bottle_size", @@ -160,17 +265,18 @@ class DrinkAdmin(admin.ModelAdmin): }, ), ( - "purchase", + "Einkauf", { "fields": ( "purchase_price_per_crate", "purchase_price_per_bottle", "purchase_price_total", + "remaining_purchase_value", ) }, ), ( - "deposit", + "Pfand", { "fields": ( "deposit_per_crate", @@ -181,14 +287,12 @@ class DrinkAdmin(admin.ModelAdmin): }, ), ( - "sales", + "Verkauf", { "fields": ( "sale_price_per_bottle", - "bottles_sold", "sales_purchase_value", "sale_price_total", - "bottles_given_away", "giveaway_purchase_value", "balance", ) @@ -198,10 +302,15 @@ class DrinkAdmin(admin.ModelAdmin): readonly_fields = ( "bottles_total", "bottles_returned", + "bottles_consumed", + "bottles_remaining", + "crates_full_returned", + "crates_remaining", "amount_per_crate", "amount_total", "purchase_price_per_bottle", "purchase_price_total", + "remaining_purchase_value", "deposit_total", "deposit_refund", "deposit_kept", @@ -213,29 +322,82 @@ class DrinkAdmin(admin.ModelAdmin): "balance", ) + @admin.display(description="Kästen voll zurück") + def crates_full_returned(self, drink: Drink): + return drink.crates_full_returned + + @admin.display(description="Kästen übrig") + def crates_remaining(self, drink: Drink): + return drink.crates_remaining + + @admin.display(description="Flaschen gesamt") + def bottles_total(self, drink: Drink): + return drink.bottles_total + + @admin.display(description="Flaschen leer zurück") + def bottles_returned(self, drink: Drink): + return drink.bottles_returned + + @admin.display(description="Flaschen verkauft") + def bottles_sold(self, drink: Drink): + return drink.bottles_sold + + @admin.display(description="Flaschen verschenkt") + def bottles_given_away(self, drink: Drink): + return drink.bottles_given_away + + @admin.display(description="Flaschen konsumiert") + def bottles_consumed(self, drink: Drink): + return drink.bottles_consumed + + @admin.display(description="Flaschen übrig") + def bottles_remaining(self, drink: Drink): + return drink.bottles_remaining + + @admin.display(description="Menge pro Kasten (l)") + def amount_per_crate(self, drink: Drink): + return drink.amount_per_crate + + @admin.display(description="Menge gesamt (l)") + def amount_total(self, drink: Drink): + return drink.amount_total + + @admin.display(description="Einkaufspreis pro Flasche") def purchase_price_per_bottle(self, drink: Drink): return euro(drink.purchase_price_per_bottle) + @admin.display(description="Einkaufspreis gesamt") def purchase_price_total(self, drink: Drink): return euro(drink.purchase_price_total) + @admin.display(description="Einkaufswert übrig") + def remaining_purchase_value(self, drink: Drink): + return euro(drink.remaining_purchase_value) + + @admin.display(description="Pfand gesamt") def deposit_total(self, drink: Drink): return euro(drink.deposit_total) + @admin.display(description="Pfand zurück") def deposit_refund(self, drink: Drink): return euro(drink.deposit_refund) + @admin.display(description="Pfand einbehalten") def deposit_kept(self, drink: Drink): return euro(drink.deposit_kept) + @admin.display(description="Einkaufswert verkauft") def sales_purchase_value(self, drink: Drink): return euro(drink.sales_purchase_value) + @admin.display(description="Verkaufserlös") def sale_price_total(self, drink: Drink): return euro(drink.sale_price_total) + @admin.display(description="Einkaufswert verschenkt") def giveaway_purchase_value(self, drink: Drink): return euro(drink.giveaway_purchase_value) + @admin.display(description="Bilanz") def balance(self, drink: Drink): return mark_safe(f"{euro(drink.balance)}")