Files
gaehsnitz/gaehsnitz/admin.py
T
flo b9c62babf1 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>
2026-05-14 22:18:57 +02:00

404 lines
12 KiB
Python

from django import forms
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 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()
class ConsumptionInline(admin.TabularInline):
model = Consumption
extra = 0
class UserPaymentInline(admin.TabularInline):
model = UserPayment
extra = 0
readonly_fields = ("created_at",)
class SetPinForm(forms.Form):
pin = forms.CharField(
label="Neue PIN (3 Ziffern)",
min_length=3,
max_length=3,
)
def clean_pin(self):
pin = self.cleaned_data["pin"]
if not pin.isdigit():
raise forms.ValidationError("PIN muss aus genau 3 Ziffern bestehen.")
return pin
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ("username", "consumed_drinks_price", "paid_amount", "open_balance")
ordering = ("username",)
list_filter = []
fieldsets = (
(
None,
{
"fields": (
"username",
"password",
"pin_status",
)
},
),
(
"BILANZ",
{
"fields": (
"consumed_drinks_price",
"paid_amount",
"open_balance",
"drinks_breakdown",
"free_drinks_breakdown",
)
},
),
)
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"
if user.pk is None:
return status
url = reverse("admin:gaehsnitz_user_set_pin", args=[user.pk])
return format_html('{} &nbsp; <a href="{}">PIN setzen</a>', status, url)
def get_urls(self):
urls = super().get_urls()
custom = [
path(
"<id>/pin/",
self.admin_site.admin_view(self.set_pin_view),
name="gaehsnitz_user_set_pin",
),
]
return custom + urls
def set_pin_view(self, request, id):
if not self.has_change_permission(request):
raise Http404
user = self.get_object(request, unquote(id))
if user is None:
raise Http404
if request.method == "POST":
form = SetPinForm(request.POST)
if form.is_valid():
user.set_pin(form.cleaned_data["pin"])
user.save(update_fields=["pin"])
messages.success(request, f"PIN für {user.username} gesetzt.")
return HttpResponseRedirect(reverse("admin:gaehsnitz_user_change", args=[user.pk]))
else:
form = SetPinForm()
context = {
**self.admin_site.each_context(request),
"title": f"PIN setzen für {user.username}",
"opts": self.model._meta,
"original": user,
"form": form,
}
return TemplateResponse(request, "admin/gaehsnitz/user/set_pin.html", context)
class YearFilter(admin.SimpleListFilter):
title = "Jahr"
parameter_name = "year"
field_name = "created_at"
def lookups(self, request, model_admin):
years = model_admin.model.objects.dates(self.field_name, "year", order="DESC")
return [(y.year, str(y.year)) for y in years]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(**{f"{self.field_name}__year": self.value()})
return queryset
@admin.register(UserPayment)
class UserPaymentAdmin(admin.ModelAdmin):
list_display = ("created_at", "user", "amount", "method", "note")
list_filter = ("method", YearFilter)
ordering = ("-created_at",)
search_fields = ("user__username", "note")
@admin.display(ordering="amount")
def amount(self, payment: UserPayment):
return euro(payment.amount)
@admin.register(Donation)
class DonationAdmin(admin.ModelAdmin):
list_display = ("date", "amount", "note")
list_filter = ("date",)
ordering = ("-date",)
search_fields = ("note",)
@admin.display(ordering="amount")
def amount(self, donation: Donation):
return euro(donation.amount)
@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):
return euro(payment.amount)
@admin.register(Drink)
class DrinkAdmin(admin.ModelAdmin):
list_display = (
"name",
"year",
"crates_purchased",
"bottles_sold",
"bottles_remaining",
"purchase_price_total",
"balance",
)
list_filter = ("year",)
fieldsets = (
(None, {"fields": ("name", "year")}),
(
"Kästen",
{
"fields": (
"crates_ordered",
"crates_purchased",
"crates_full_returned",
"crates_returned",
"crates_remaining",
)
},
),
(
"Flaschen",
{
"fields": (
"bottles_per_crate",
"bottles_total",
"bottles_returned",
"bottles_sold",
"bottles_given_away",
"bottles_consumed",
"bottles_remaining",
)
},
),
(
"Menge",
{
"fields": (
"bottle_size",
"amount_per_crate",
"amount_total",
)
},
),
(
"Einkauf",
{
"fields": (
"purchase_price_per_crate",
"purchase_price_per_bottle",
"purchase_price_total",
"remaining_purchase_value",
)
},
),
(
"Pfand",
{
"fields": (
"deposit_per_crate",
"deposit_total",
"deposit_refund",
"deposit_kept",
)
},
),
(
"Verkauf",
{
"fields": (
"sale_price_per_bottle",
"sales_purchase_value",
"sale_price_total",
"giveaway_purchase_value",
"balance",
)
},
),
)
readonly_fields = (
"bottles_total",
"bottles_returned",
"bottles_consumed",
"bottles_remaining",
"crates_full_returned",
"crates_remaining",
"amount_per_crate",
"amount_total",
"purchase_price_per_bottle",
"purchase_price_total",
"remaining_purchase_value",
"deposit_total",
"deposit_refund",
"deposit_kept",
"bottles_sold",
"sales_purchase_value",
"sale_price_total",
"bottles_given_away",
"giveaway_purchase_value",
"balance",
)
@admin.display(description="Kästen voll zurück")
def crates_full_returned(self, drink: Drink):
return drink.crates_full_returned
@admin.display(description="Kästen übrig")
def crates_remaining(self, drink: Drink):
return drink.crates_remaining
@admin.display(description="Flaschen gesamt")
def bottles_total(self, drink: Drink):
return drink.bottles_total
@admin.display(description="Flaschen leer zurück")
def bottles_returned(self, drink: Drink):
return drink.bottles_returned
@admin.display(description="Flaschen verkauft")
def bottles_sold(self, drink: Drink):
return drink.bottles_sold
@admin.display(description="Flaschen verschenkt")
def bottles_given_away(self, drink: Drink):
return drink.bottles_given_away
@admin.display(description="Flaschen konsumiert")
def bottles_consumed(self, drink: Drink):
return drink.bottles_consumed
@admin.display(description="Flaschen übrig")
def bottles_remaining(self, drink: Drink):
return drink.bottles_remaining
@admin.display(description="Menge pro Kasten (l)")
def amount_per_crate(self, drink: Drink):
return drink.amount_per_crate
@admin.display(description="Menge gesamt (l)")
def amount_total(self, drink: Drink):
return drink.amount_total
@admin.display(description="Einkaufspreis pro Flasche")
def purchase_price_per_bottle(self, drink: Drink):
return euro(drink.purchase_price_per_bottle)
@admin.display(description="Einkaufspreis gesamt")
def purchase_price_total(self, drink: Drink):
return euro(drink.purchase_price_total)
@admin.display(description="Einkaufswert übrig")
def remaining_purchase_value(self, drink: Drink):
return euro(drink.remaining_purchase_value)
@admin.display(description="Pfand gesamt")
def deposit_total(self, drink: Drink):
return euro(drink.deposit_total)
@admin.display(description="Pfand zurück")
def deposit_refund(self, drink: Drink):
return euro(drink.deposit_refund)
@admin.display(description="Pfand einbehalten")
def deposit_kept(self, drink: Drink):
return euro(drink.deposit_kept)
@admin.display(description="Einkaufswert verkauft")
def sales_purchase_value(self, drink: Drink):
return euro(drink.sales_purchase_value)
@admin.display(description="Verkaufserlös")
def sale_price_total(self, drink: Drink):
return euro(drink.sale_price_total)
@admin.display(description="Einkaufswert verschenkt")
def giveaway_purchase_value(self, drink: Drink):
return euro(drink.giveaway_purchase_value)
@admin.display(description="Bilanz")
def balance(self, drink: Drink):
return mark_safe(f"<b>{euro(drink.balance)}</b>")