From 5c8ebbbbf6ae056802b548aa86029d1fdcde8292 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Fri, 17 Mar 2023 20:27:41 +0100 Subject: [PATCH] finance: improve confirm overview, polish admin pages --- jdav_web/finance/admin.py | 100 +++- jdav_web/finance/models.py | 191 +++++++- .../templates/admin/confirmed_statement.html | 72 +++ .../admin/overview_submitted_statement.html | 126 ++++- jdav_web/static/jet/css/_admin-view.scss | 193 ++++++++ jdav_web/static/jet/css/_base.scss | 1 + .../static/jet/css/themes/jdav-green/base.css | 459 ++++++++++++++++++ .../jet/css/themes/jdav-green/base.css.map | 4 +- .../change_form_object_tools.html | 13 + .../change_form_object_tools.html | 23 + .../change_form_object_tools.html | 0 11 files changed, 1126 insertions(+), 56 deletions(-) create mode 100644 jdav_web/finance/templates/admin/confirmed_statement.html create mode 100644 jdav_web/static/jet/css/_admin-view.scss create mode 100644 jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html rename jdav_web/templates/admin/finance/{statement => statementunsubmitted}/change_form_object_tools.html (100%) diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 7c0a0e4..f277f3b 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -7,11 +7,13 @@ from functools import update_wrapper from django.utils.translation import gettext_lazy as _ from django.shortcuts import render -from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed +from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ + StatementUnSubmitted + @admin.register(Ledger) class LedgerAdmin(admin.ModelAdmin): - pass + search_fields = ('name', ) class BillOnStatementInline(admin.TabularInline): @@ -29,8 +31,8 @@ class BillOnStatementInline(admin.TabularInline): return super(BillOnStatementInline, self).get_readonly_fields(request, obj) -@admin.register(Statement) -class StatementAdmin(admin.ModelAdmin): +@admin.register(StatementUnSubmitted) +class StatementUnSubmitteddAdmin(admin.ModelAdmin): fields = ['short_description', 'explanation', 'excursion', 'submitted'] inlines = [BillOnStatementInline] @@ -65,13 +67,13 @@ class StatementAdmin(admin.ModelAdmin): if statement.submitted: messages.error(request, _("%(name)s is already submitted.") % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_change' % (statement._meta.app_label, statement._meta.model_name), args=(statement.pk,))) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) if "apply" in request.POST: - statement.submit() + statement.submit(get_member(request)) messages.success(request, _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_change' % (statement._meta.app_label, statement._meta.model_name), args=(statement.pk,))) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) context = dict(self.admin_site.each_context(request), title=_('Submit statement'), opts=self.opts, @@ -82,7 +84,7 @@ class StatementAdmin(admin.ModelAdmin): class TransactionOnSubmittedStatementInline(admin.TabularInline): model = Transaction - fields = ['amount', 'member', 'reference'] + fields = ['amount', 'member', 'reference', 'ledger'] formfield_overrides = { TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} } @@ -105,6 +107,8 @@ class BillOnSubmittedStatementInline(BillOnStatementInline): @admin.register(StatementSubmitted) class StatementSubmittedAdmin(admin.ModelAdmin): fields = ['short_description', 'explanation', 'excursion', 'submitted'] + list_display = ['__str__', 'is_valid', 'submitted_date', 'submitted_by'] + ordering = ('-submitted_date',) inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline] def has_add_permission(self, request, obj=None): @@ -142,17 +146,50 @@ class StatementSubmittedAdmin(admin.ModelAdmin): return custom_urls + urls def overview_view(self, request, object_id): - statement = Statement.objects.get(pk=object_id) + statement = StatementSubmitted.objects.get(pk=object_id) if not statement.submitted: messages.error(request, _("%(name)s is not yet submitted.") % {'name': str(statement)}) return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + if "transaction_execution_confirm" in request.POST: + res = statement.confirm(confirmer=get_member(request)) + if not res: + # this should NOT happen! + messages.error(request, + _("An error occured while trying to confirm %(name)s. Please try again.") % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name))) + + messages.success(request, + _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") + % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) if "confirm" in request.POST: - statement.confirmed = True + res = statement.validity + if res == Statement.VALID: + context = dict(self.admin_site.each_context(request), + title=_('Statement confirmed'), + opts=self.opts, + statement=statement) + return render(request, 'admin/confirmed_statement.html', context=context) + elif res == Statement.NON_MATCHING_TRANSACTIONS: + messages.error(request, + _("Transactions do not match the covered expenses. Please correct the mistakes listed below.") + % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + elif res == Statement.MISSING_LEDGER: + messages.error(request, + _("Some transactions have no ledger configured. Please fill in the gaps.") + % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) + + if "reject" in request.POST: + statement.submitted = False statement.save() - for trans in statement.transaction_set.all(): - trans.confirmed = True - trans.save() + messages.success(request, + _("Successfully rejected %(name)s. The requestor can reapply, when needed.") + % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) if "generate_transactions" in request.POST: if statement.transaction_set.count() > 0: @@ -167,6 +204,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin): title=_('View submitted statement'), opts=self.opts, statement=statement, + transaction_issues=statement.transaction_issues, nights=statement.excursion.night_count, price_per_night=statement.real_night_cost, duration=statement.excursion.duration, @@ -181,33 +219,49 @@ class StatementSubmittedAdmin(admin.ModelAdmin): transportation_per_yl=statement.transportation_per_yl, total_per_yl=statement.total_per_yl, total_staff=statement.total_staff, - total=statement.total()) + total=statement.total) return render(request, 'admin/overview_submitted_statement.html', context=context) def reduce_transactions_view(self, request, object_id): - statement = Statement.objects.get(pk=object_id) + statement = StatementSubmitted.objects.get(pk=object_id) statement.reduce_transactions() messages.success(request, _("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + return HttpResponseRedirect(request.GET['redirectTo']) + #return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + @admin.register(StatementConfirmed) class StatementConfirmedAdmin(admin.ModelAdmin): fields = ['short_description', 'explanation', 'excursion', 'confirmed'] - readonly_fields = fields + #readonly_fields = fields + list_display = ['__str__', 'total_pretty', 'confirmed_date', 'confirmed_by'] + ordering = ('-confirmed_date',) - -@admin.register(Receipt) -class ReceiptAdmin(admin.ModelAdmin): - pass + def has_add_permission(self, request, obj=None): + return False @admin.register(Transaction) class TransactionAdmin(admin.ModelAdmin): - pass + list_display = ['member', 'ledger', 'amount', 'reference', 'statement', 'confirmed', + 'confirmed_date', 'confirmed_by'] + list_filter = ('ledger', 'member', 'statement', 'confirmed') + search_fields = ('reference', ) + fields = ['reference', 'amount', 'member', 'ledger', 'statement'] + readonly_fields = fields @admin.register(Bill) class BillAdmin(admin.ModelAdmin): - list_display = ['short_description', 'pretty_amount', 'paid_by', 'refunded'] + list_display = ['__str__', 'statement', 'short_description', 'pretty_amount', 'paid_by', 'refunded'] + list_filter = ('statement', 'paid_by', 'refunded') + search_fields = ('reference', 'statement') + + +def get_member(request): + if not hasattr(request.user, 'member'): + return None + else: + return request.user.member diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index 9f10ef5..f6eba79 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -1,5 +1,7 @@ import math from itertools import groupby +from decimal import Decimal, ROUND_HALF_DOWN +from django.utils import timezone from django.db import models from django.utils.translation import gettext_lazy as _ @@ -13,8 +15,28 @@ class Ledger(models.Model): def __str__(self): return self.name + class Meta: + verbose_name = _('Ledger') + verbose_name_plural = _('Ledgers') + + +class TransactionIssue: + def __init__(self, member, current, target): + self.member, self.current, self. target = member, current, target + + @property + def difference(self): + return self.target - self.current + + +class StatementManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(submitted=False, confirmed=False) + class Statement(models.Model): + MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, VALID = 0, 1, 2 + ALLOWANCE_PER_DAY = 10 short_description = models.CharField(verbose_name=_('Short description'), @@ -30,9 +52,24 @@ class Statement(models.Model): night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=3) submitted = models.BooleanField(verbose_name=_('Submitted'), default=False) + submitted_date = models.DateTimeField(verbose_name=_('Submitted on'), default=None, null=True) confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False) + confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True) + + submitted_by = models.ForeignKey(Member, verbose_name=_('Submitted by'), + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='submitted_statements') + confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'), + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='confirmed_statements') class Meta: + verbose_name = _('Statement') + verbose_name_plural = _('Statements') permissions = [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')] def __str__(self): @@ -41,9 +78,73 @@ class Statement(models.Model): else: return self.short_description - def submit(self): + def submit(self, submitter=None): self.submitted = True + self.submitted_date = timezone.now() + self.submitted_by = submitter + self.save() + + @property + def transaction_issues(self): + needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered] + + if self.excursion is not None: + needed_paiments.extend([(yl, self.real_per_yl) for yl in self.excursion.jugendleiter.all()]) + + needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk) + target = map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0])) + + transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk) + current = dict(map(lambda p: (p[0], sum([t.amount for t in p[1]])), groupby(transactions, lambda trans: trans.member))) + + issues = [] + for member, amount in target: + if amount == 0: + continue + elif member not in current: + issue = TransactionIssue(member=member, current=0, target=amount) + issues.append(issue) + elif current[member] != amount: + issue = TransactionIssue(member=member, current=current[member], target=amount) + issues.append(issue) + return issues + + @property + def ledgers_configured(self): + return all([trans.ledger is not None for trans in self.transaction_set.all()]) + + @property + def transactions_match_expenses(self): + return len(self.transaction_issues) == 0 + + def is_valid(self): + return self.ledgers_configured and self.transactions_match_expenses + is_valid.boolean = True + is_valid.short_description = _('Ready to confirm') + + @property + def validity(self): + if not self.transactions_match_expenses: + return Statement.NON_MATCHING_TRANSACTIONS + if not self.ledgers_configured: + return Statement.MISSING_LEDGER + else: + return Statement.VALID + + def confirm(self, confirmer=None): + if not self.validity == Statement.VALID: + return False + + self.confirmed = True + self.confirmed_date = timezone.now() + self.confirmed_by = confirmer + for trans in self.transaction_set.all(): + trans.confirmed = True + trans.confirmed_date = timezone.now() + trans.confirmed_by = confirmer + trans.save() self.save() + return True def generate_transactions(self): # bills @@ -58,20 +159,29 @@ class Statement(models.Model): return for yl in self.excursion.jugendleiter.all(): - real_per_yl = self.total_staff / self.excursion.jugendleiter.count() - ref = _("Compensation for %(excu)s.") % {'excu': self.excursion.name} - Transaction(statement=self, member=yl, amount=real_per_yl, confirmed=False, reference=ref).save() + ref = _("Compensation for %(excu)s") % {'excu': self.excursion.name} + Transaction(statement=self, member=yl, amount=self.real_per_yl, confirmed=False, reference=ref).save() def reduce_transactions(self): - transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk) - for member, transaction_group in groupby(transactions, lambda trans: trans.member): + # to minimize the number of needed bank transactions, we bundle transactions from same ledger to + # same member + transactions = self.transaction_set.all() + if any((t.ledger is None for t in transactions)): + return + + sort_key = lambda trans: (trans.member.pk, trans.ledger.pk) + group_key = lambda trans: (trans.member, trans.ledger) + transactions = sorted(transactions, key=sort_key) + for pair, transaction_group in groupby(transactions, group_key): + member, ledger = pair grp = list(transaction_group) if len(grp) == 1: continue new_amount = sum((trans.amount for trans in grp)) new_ref = "\n".join((trans.reference for trans in grp)) - Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref).save() + Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref, + ledger=ledger).save() for trans in grp: trans.delete() @@ -95,14 +205,14 @@ class Statement(models.Model): if self.excursion is None: return 0 - return self.excursion.kilometers_traveled * self.euro_per_km + return cvt_to_decimal(self.excursion.kilometers_traveled * self.euro_per_km) @property def allowance_per_yl(self): if self.excursion is None: return 0 - return self.excursion.duration * self.ALLOWANCE_PER_DAY + return cvt_to_decimal(self.excursion.duration * self.ALLOWANCE_PER_DAY) @property def real_night_cost(self): @@ -113,7 +223,7 @@ class Statement(models.Model): if self.excursion is None: return 0 - return float(self.excursion.night_count * self.real_night_cost) + return self.excursion.night_count * self.real_night_cost @property def total_per_yl(self): @@ -121,6 +231,13 @@ class Statement(models.Model): + self.allowance_per_yl \ + self.nights_per_yl + @property + def real_per_yl(self): + if self.excursion is None: + return 0 + + return self.total_staff / self.excursion.staff_count + @property def total_staff(self): return self.total_per_yl * self.real_staff_count @@ -148,8 +265,27 @@ class Statement(models.Model): else: return 2 + math.ceil((participant_count - 7) / 7) + @property def total(self): - return float(self.total_bills) + self.total_staff + return self.total_bills + self.total_staff + + def total_pretty(self): + return "{}€".format(self.total) + total_pretty.short_description = _('Total') + + +class StatementUnSubmittedManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(submitted=False, confirmed=False) + + +class StatementUnSubmitted(Statement): + objects = StatementUnSubmittedManager() + + class Meta: + proxy = True + verbose_name = _('Statement in preparation') + verbose_name_plural = _('Statements in preparation') class StatementSubmittedManager(models.Manager): @@ -177,8 +313,8 @@ class StatementConfirmed(Statement): class Meta: proxy = True - verbose_name = _('Confirmed statement') - verbose_name_plural = _('Confirmed statements') + verbose_name = _('Paid statement') + verbose_name_plural = _('Paid statements') permissions = (('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),) @@ -188,7 +324,7 @@ class Bill(models.Model): explanation = models.TextField(verbose_name=_('Explanation'), blank=True) amount = models.DecimalField(max_digits=6, decimal_places=2, default=0) - paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True, + paid_by = models.ForeignKey(Member, verbose_name=_('Authorized by'), null=True, on_delete=models.SET_NULL) costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False) refunded = models.BooleanField(verbose_name=_('Refunded'), default=False) @@ -199,20 +335,39 @@ class Bill(models.Model): return "{} ({}€)".format(self.short_description, self.amount) def pretty_amount(self): - return "{} €".format(self.amount) + return "{}€".format(self.amount) pretty_amount.admin_order_field = 'amount' + class Meta: + verbose_name = _('Bill') + verbose_name_plural = _('Bills') + class Transaction(models.Model): reference = models.TextField(verbose_name=_('Reference')) amount = models.DecimalField(max_digits=6, decimal_places=2) member = models.ForeignKey(Member, verbose_name=_('Recipient'), on_delete=models.CASCADE) + ledger = models.ForeignKey(Ledger, blank=False, null=True, default=None, verbose_name=_('Ledger'), + on_delete=models.SET_NULL) statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) - confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False) + confirmed = models.BooleanField(verbose_name=_('Paid'), default=False) + confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True) + confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'), + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='confirmed_transactions') + + def __str__(self): + return "T#{}".format(self.pk) + + class Meta: + verbose_name = _('Transaction') + verbose_name_plural = _('Transactions') class Receipt(models.Model): @@ -221,3 +376,7 @@ class Receipt(models.Model): on_delete=models.CASCADE) amount = models.DecimalField(max_digits=6, decimal_places=2) comments = models.TextField() + + +def cvt_to_decimal(f): + return Decimal(f).quantize(Decimal('.01'), rounding=ROUND_HALF_DOWN) diff --git a/jdav_web/finance/templates/admin/confirmed_statement.html b/jdav_web/finance/templates/admin/confirmed_statement.html new file mode 100644 index 0000000..793f8fd --- /dev/null +++ b/jdav_web/finance/templates/admin/confirmed_statement.html @@ -0,0 +1,72 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} + + + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} admin-view +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{% translate "Paying statement" %}

+ +

+{% blocktrans %}The statement is valid. Please execute the following transactions and then proceed by +finalizing the confirmation. +{% endblocktrans %} +

+ +

+ + + + + + + {% for transaction in statement.transaction_set.all %} + + + + + + + + {% endfor %} +
+ {% trans "IBAN" %}{% trans "Amount" %}{% trans "Reference" %}{% trans "Ledger" %}
+ {{ transaction.member }} + + {{ transaction.member.iban }} + + {{ transaction.amount }}€ + + {{ transaction.reference }} + + {{ transaction.ledger }} +
+

+ +
+ {% csrf_token %} +

+ + {% blocktrans %}I did execute the listed transactions.{% endblocktrans %} +

+ +
+{% endblock %} diff --git a/jdav_web/finance/templates/admin/overview_submitted_statement.html b/jdav_web/finance/templates/admin/overview_submitted_statement.html index 29635fa..7743266 100644 --- a/jdav_web/finance/templates/admin/overview_submitted_statement.html +++ b/jdav_web/finance/templates/admin/overview_submitted_statement.html @@ -1,8 +1,6 @@ {% extends "admin/base_site.html" %} {% load i18n admin_urls static %} -{% load finance_extras %} - {% block extrahead %} {{ block.super }} {{ media }} @@ -11,7 +9,7 @@ {% endblock %} -{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} admin-view {% endblock %} {% block breadcrumbs %} @@ -28,16 +26,25 @@

{% translate "Bills" %}

-