From 1c0744811b0e3779d5fa6aeccb0bcb6c989f0a66 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Fri, 28 Nov 2025 22:37:05 +0100 Subject: [PATCH] chore(finance/*): reformat using ruff (#16) --- jdav_web/finance/admin.py | 500 ++++++++++++++++++---------- jdav_web/finance/apps.py | 6 +- jdav_web/finance/models.py | 665 ++++++++++++++++++++++--------------- jdav_web/finance/rules.py | 8 +- 4 files changed, 737 insertions(+), 442 deletions(-) diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index be05fdf..37f6afe 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -1,40 +1,48 @@ import logging -from django.contrib import admin, messages -from django.utils.safestring import mark_safe -from django import forms -from django.forms import Textarea, ClearableFileInput -from django.http import HttpResponse, HttpResponseRedirect -from django.db.models import TextField, Q -from django.urls import path, reverse from functools import update_wrapper -from django.utils.translation import gettext_lazy as _ -from django.shortcuts import render -from django.conf import settings -from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin -from utils import get_member, RestrictedFileField - -from rules.contrib.admin import ObjectPermissionsModelAdmin +from contrib.admin import CommonAdminInlineMixin +from contrib.admin import CommonAdminMixin +from django import forms +from django.conf import settings +from django.contrib import admin +from django.contrib import messages +from django.db.models import TextField +from django.forms import ClearableFileInput +from django.forms import Textarea +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import path +from django.urls import reverse +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from members.pdf import render_tex_with_attachments +from utils import get_member -from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ - StatementUnSubmitted, BillOnStatementProxy +from .models import Bill +from .models import BillOnStatementProxy +from .models import Ledger +from .models import Statement +from .models import StatementConfirmed +from .models import StatementSubmitted +from .models import StatementUnSubmitted +from .models import Transaction logger = logging.getLogger(__name__) @admin.register(Ledger) class LedgerAdmin(admin.ModelAdmin): - search_fields = ('name', ) + search_fields = ("name",) class BillOnStatementInlineForm(forms.ModelForm): class Meta: model = BillOnStatementProxy - fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] + fields = ["short_description", "explanation", "amount", "paid_by", "proof"] widgets = { - 'proof': ClearableFileInput(attrs={'accept': 'application/pdf,image/jpeg,image/png'}), - 'explanation': Textarea(attrs={'rows': 1, 'cols': 40}) + "proof": ClearableFileInput(attrs={"accept": "application/pdf,image/jpeg,image/png"}), + "explanation": Textarea(attrs={"rows": 1, "cols": 40}), } @@ -51,24 +59,38 @@ def decorate_statement_view(model, perm=None): try: statement = model.objects.get(pk=object_id) except model.DoesNotExist: - messages.error(request, _('Statement not found.')) - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) - permitted = self.has_change_permission(request, statement) if not perm else request.user.has_perm(perm) + messages.error(request, _("Statement not found.")) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name) + ) + ) + permitted = ( + self.has_change_permission(request, statement) + if not perm + else request.user.has_perm(perm) + ) if not permitted: - messages.error(request, _('Insufficient permissions.')) - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) + messages.error(request, _("Insufficient permissions.")) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name) + ) + ) return fun(self, request, statement) + return aux + return decorator @admin.register(Statement) class StatementAdmin(CommonAdminMixin, admin.ModelAdmin): - fields = ['short_description', 'explanation', 'excursion', 'status'] - list_display = ['__str__', 'total_pretty', 'created_by', 'submitted_date', 'status_badge'] - list_filter = ['status'] - search_fields = ('excursion__name', 'short_description') - ordering = ['-submitted_date'] + fields = ["short_description", "explanation", "excursion", "status"] + list_display = ["__str__", "total_pretty", "created_by", "submitted_date", "status_badge"] + list_filter = ["status"] + search_fields = ("excursion__name", "short_description") + ordering = ["-submitted_date"] inlines = [BillOnStatementInline] list_per_page = 25 @@ -87,7 +109,7 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin): return super().has_delete_permission(request, obj) def save_model(self, request, obj, form, change): - if not change and hasattr(request.user, 'member'): + if not change and hasattr(request.user, "member"): obj.created_by = request.user.member super().save_model(request, obj, form, change) @@ -95,14 +117,14 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin): if obj is not None and obj.excursion: # if the object exists and an excursion is set, show the excursion (read only) # instead of the short description - return ['excursion', 'explanation', 'status'] + return ["excursion", "explanation", "status"] else: # if the object is newly created or no excursion is set, require # a short description - return ['short_description', 'explanation', 'status'] + return ["short_description", "explanation", "status"] def get_readonly_fields(self, request, obj=None): - readonly_fields = ['status', 'excursion'] + readonly_fields = ["status", "excursion"] if obj is not None and obj.submitted: return readonly_fields + self.fields else: @@ -128,208 +150,338 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin): path( "/submit/", wrap(self.submit_view), - name="%s_%s_submit" % (self.opts.app_label, self.opts.model_name), + name="{}_{}_submit".format(self.opts.app_label, self.opts.model_name), ), path( "/overview/", wrap(self.overview_view), - name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name), + name="{}_{}_overview".format(self.opts.app_label, self.opts.model_name), ), path( "/reduce_transactions/", wrap(self.reduce_transactions_view), - name="%s_%s_reduce_transactions" % (self.opts.app_label, self.opts.model_name), + name="{}_{}_reduce_transactions".format(self.opts.app_label, self.opts.model_name), ), path( "/unconfirm/", wrap(self.unconfirm_view), - name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name), + name="{}_{}_unconfirm".format(self.opts.app_label, self.opts.model_name), ), path( "/summary/", wrap(self.statement_summary_view), - name="%s_%s_summary" % (self.opts.app_label, self.opts.model_name), + name="{}_{}_summary".format(self.opts.app_label, self.opts.model_name), ), ] return custom_urls + urls @decorate_statement_view(StatementUnSubmitted) def submit_view(self, request, statement): - if statement.submitted: # pragma: no cover - logger.error(f"submit_view reached with submitted statement {statement}. This should not happen.") - messages.error(request, - _("%(name)s is already submitted.") % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) + if statement.submitted: # pragma: no cover + logger.error( + f"submit_view reached with submitted statement {statement}. This should not happen." + ) + messages.error(request, _("%(name)s is already submitted.") % {"name": str(statement)}) + return HttpResponseRedirect( + reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)) + ) if "apply" in request.POST: 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_changelist' % (self.opts.app_label, self.opts.model_name))) + 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:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)) + ) if statement.excursion: memberlist = statement.excursion - context = dict(self.admin_site.each_context(request), - title=_('Finance overview'), - opts=self.opts, - memberlist=memberlist, - object=memberlist, - ljp_contributions=memberlist.payable_ljp_contributions, - total_relative_costs=memberlist.total_relative_costs, - **memberlist.statement.template_context()) - return render(request, 'admin/freizeit_finance_overview.html', context=context) + context = dict( + self.admin_site.each_context(request), + title=_("Finance overview"), + opts=self.opts, + memberlist=memberlist, + object=memberlist, + ljp_contributions=memberlist.payable_ljp_contributions, + total_relative_costs=memberlist.total_relative_costs, + **memberlist.statement.template_context(), + ) + return render(request, "admin/freizeit_finance_overview.html", context=context) else: - context = dict(self.admin_site.each_context(request), - title=_('Submit statement'), + context = dict( + self.admin_site.each_context(request), + title=_("Submit statement"), opts=self.opts, - statement=statement) - return render(request, 'admin/submit_statement.html', context=context) + statement=statement, + ) + return render(request, "admin/submit_statement.html", context=context) @decorate_statement_view(StatementSubmitted) def overview_view(self, request, statement): - if not statement.submitted: # pragma: no cover - logger.error(f"overview_view reached with unsubmitted statement {statement}. This should not happen.") - 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 or "transaction_execution_confirm_and_send" in request.POST: + if not statement.submitted: # pragma: no cover + logger.error( + f"overview_view reached with unsubmitted statement {statement}. This should not happen." + ) + messages.error(request, _("%(name)s is not yet submitted.") % {"name": str(statement)}) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + ) + if ( + "transaction_execution_confirm" in request.POST + or "transaction_execution_confirm_and_send" in request.POST + ): res = statement.confirm(confirmer=get_member(request)) - if not res: # pragma: no cover + if not res: # pragma: no cover # this should NOT happen! - logger.error(f"Error occured while confirming {statement}, this should not be possible.") - 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))) + logger.error( + f"Error occured while confirming {statement}, this should not be possible." + ) + messages.error( + request, + _("An error occured while trying to confirm %(name)s. Please try again.") + % {"name": str(statement)}, + ) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name) + ) + ) if "transaction_execution_confirm_and_send" in request.POST: - statement.send_summary(cc=[request.user.member.email] if hasattr(request.user, 'member') else []) + statement.send_summary( + cc=[request.user.member.email] if hasattr(request.user, "member") else [] + ) messages.success(request, _("Successfully sent receipt to the office.")) - messages.success(request, - _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") - % {'name': str(statement)}) - download_link = reverse('admin:%s_%s_summary' % (self.opts.app_label, self.opts.model_name), - args=(statement.pk,)) - messages.success(request, - mark_safe(_("You can download a receipt.") % {'link': download_link})) - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (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)}, + ) + download_link = reverse( + "admin:{}_{}_summary".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + messages.success( + request, + mark_safe( + _("You can download a receipt.") + % {"link": download_link} + ), + ) + return HttpResponseRedirect( + reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)) + ) if "confirm" in request.POST: 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) + 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,))) + messages.error( + request, + _( + "Transactions do not match the covered expenses. Please correct the mistakes listed below." + ) + % {"name": str(statement)}, + ) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_overview".format(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,))) + messages.error( + request, + _("Some transactions have no ledger configured. Please fill in the gaps.") + % {"name": str(statement)}, + ) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + ) elif res == Statement.INVALID_ALLOWANCE_TO: - messages.error(request, - _("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion.")) - return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) - elif res == Statement.INVALID_TOTAL: # pragma: no cover + messages.error( + request, + _( + "The configured recipients for the allowance don't match the regulations. Please correct this on the excursion." + ), + ) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + ) + elif res == Statement.INVALID_TOTAL: # pragma: no cover logger.error(f"INVALID_TOTAL reached on {statement}.") - messages.error(request, - _("The calculated total amount does not match the sum of all transactions. This is most likely a bug.")) - return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) - else: # pragma: no cover + messages.error( + request, + _( + "The calculated total amount does not match the sum of all transactions. This is most likely a bug." + ), + ) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_overview".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + ) + else: # pragma: no cover logger.error(f"Statement.validity returned invalid value for {statement}.") - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name) + ) + ) if "reject" in request.POST: statement.status = Statement.UNSUBMITTED statement.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))) + messages.success( + request, + _("Successfully rejected %(name)s. The requestor can reapply, when needed.") + % {"name": str(statement)}, + ) + return HttpResponseRedirect( + reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)) + ) if "generate_transactions" in request.POST: if statement.transaction_set.count() > 0: - messages.error(request, - _("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(statement)}) + messages.error( + request, + _( + "%(name)s already has transactions. Please delete them first, if you want to generate new ones" + ) + % {"name": str(statement)}, + ) else: success = statement.generate_transactions() if success: - messages.success(request, - _("Successfully generated transactions for %(name)s") % {'name': str(statement)}) + messages.success( + request, + _("Successfully generated transactions for %(name)s") + % {"name": str(statement)}, + ) else: - messages.error(request, - _("Error while generating transactions for %(name)s. Do all bills have a payer and, if this statement is attached to an excursion, was a person selected that receives the subsidies?") % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) - context = dict(self.admin_site.each_context(request), - title=_('View submitted statement'), - opts=self.opts, - statement=statement, - settings=settings, - transaction_issues=statement.transaction_issues, - **statement.template_context()) - - return render(request, 'admin/overview_submitted_statement.html', context=context) + messages.error( + request, + _( + "Error while generating transactions for %(name)s. Do all bills have a payer and, if this statement is attached to an excursion, was a person selected that receives the subsidies?" + ) + % {"name": str(statement)}, + ) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + ) + context = dict( + self.admin_site.each_context(request), + title=_("View submitted statement"), + opts=self.opts, + statement=statement, + settings=settings, + transaction_issues=statement.transaction_issues, + **statement.template_context(), + ) + + return render(request, "admin/overview_submitted_statement.html", context=context) @decorate_statement_view(StatementSubmitted) def reduce_transactions_view(self, request, statement): statement.reduce_transactions() - messages.success(request, - _("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) - return HttpResponseRedirect(request.GET['redirectTo']) + messages.success( + request, _("Successfully reduced transactions for %(name)s.") % {"name": str(statement)} + ) + return HttpResponseRedirect(request.GET["redirectTo"]) - @decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') + @decorate_statement_view(StatementConfirmed, perm="finance.may_manage_confirmed_statements") def unconfirm_view(self, request, statement): - if not statement.confirmed: # pragma: no cover + if not statement.confirmed: # pragma: no cover logger.error(f"unconfirm_view reached with unconfirmed statement {statement}.") - messages.error(request, - _("%(name)s is not yet confirmed.") % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + messages.error(request, _("%(name)s is not yet confirmed.") % {"name": str(statement)}) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + ) if "unconfirm" in request.POST: statement.status = Statement.SUBMITTED statement.confirmed_date = None statement.confired_by = None statement.save() - messages.success(request, - _("Successfully unconfirmed %(name)s. I hope you know what you are doing.") - % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) - - context = dict(self.admin_site.each_context(request), - title=_('Unconfirm statement'), - opts=self.opts, - statement=statement) - - return render(request, 'admin/unconfirm_statement.html', context=context) - - @decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') + messages.success( + request, + _("Successfully unconfirmed %(name)s. I hope you know what you are doing.") + % {"name": str(statement)}, + ) + return HttpResponseRedirect( + reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name)) + ) + + context = dict( + self.admin_site.each_context(request), + title=_("Unconfirm statement"), + opts=self.opts, + statement=statement, + ) + + return render(request, "admin/unconfirm_statement.html", context=context) + + @decorate_statement_view(StatementConfirmed, perm="finance.may_manage_confirmed_statements") def statement_summary_view(self, request, statement): - if not statement.confirmed: # pragma: no cover + if not statement.confirmed: # pragma: no cover logger.error(f"statement_summary_view reached with unconfirmed statement {statement}.") - messages.error(request, - _("%(name)s is not yet confirmed.") % {'name': str(statement)}) - return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + messages.error(request, _("%(name)s is not yet confirmed.") % {"name": str(statement)}) + return HttpResponseRedirect( + reverse( + "admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name), + args=(statement.pk,), + ) + ) excursion = statement.excursion - context = dict(statement=statement.template_context(), excursion=excursion, settings=settings) + context = dict( + statement=statement.template_context(), excursion=excursion, settings=settings + ) - pdf_filename = f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else f"Abrechnungsbeleg" + pdf_filename = ( + f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else "Abrechnungsbeleg" + ) attachments = [bill.proof.path for bill in statement.bills_covered if bill.proof] - return render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex', context, attachments) + return render_tex_with_attachments( + pdf_filename, "finance/statement_summary.tex", context, attachments + ) - statement_summary_view.short_description = _('Download summary') + statement_summary_view.short_description = _("Download summary") class TransactionOnSubmittedStatementInline(admin.TabularInline): model = Transaction - fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger'] - formfield_overrides = { - TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} - } - readonly_fields = ['text_length_warning'] + fields = ["amount", "member", "reference", "text_length_warning", "ledger"] + formfield_overrides = {TextField: {"widget": Textarea(attrs={"rows": 1, "cols": 40})}} + readonly_fields = ["text_length_warning"] extra = 0 def text_length_warning(self, obj): @@ -340,6 +492,7 @@ class TransactionOnSubmittedStatementInline(admin.TabularInline): return mark_safe(f'{len_string}') return len_string + text_length_warning.short_description = _("Length") @@ -347,13 +500,11 @@ class BillOnSubmittedStatementInline(BillOnStatementInline): model = BillOnStatementProxy extra = 0 sortable_options = [] - fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] - formfield_overrides = { - TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} - } + fields = ["short_description", "explanation", "amount", "paid_by", "proof", "costs_covered"] + formfield_overrides = {TextField: {"widget": Textarea(attrs={"rows": 1, "cols": 40})}} def get_readonly_fields(self, request, obj=None): - return ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] + return ["short_description", "explanation", "amount", "paid_by", "proof"] @admin.register(Transaction) @@ -361,16 +512,25 @@ class TransactionAdmin(admin.ModelAdmin): """The transaction admin site. This is only used to display transactions. All editing is disabled on this site. All transactions should be changed on the respective statement at the correct stage of the approval chain.""" - 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'] + + 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"] def get_readonly_fields(self, request, obj=None): if obj is not None and obj.confirmed: return self.fields - return super(TransactionAdmin, self).get_readonly_fields(request, obj) + return super().get_readonly_fields(request, obj) def has_add_permission(self, request, obj=None): # To preserve integrity, no one is allowed to add transactions @@ -387,6 +547,6 @@ class TransactionAdmin(admin.ModelAdmin): @admin.register(Bill) class BillAdmin(admin.ModelAdmin): - list_display = ['__str__', 'statement', 'explanation', 'pretty_amount', 'paid_by', 'refunded'] - list_filter = ('statement', 'paid_by', 'refunded') - search_fields = ('reference', 'statement') + list_display = ["__str__", "statement", "explanation", "pretty_amount", "paid_by", "refunded"] + list_filter = ("statement", "paid_by", "refunded") + search_fields = ("reference", "statement") diff --git a/jdav_web/finance/apps.py b/jdav_web/finance/apps.py index 5233c0d..d71ee1f 100644 --- a/jdav_web/finance/apps.py +++ b/jdav_web/finance/apps.py @@ -3,6 +3,6 @@ from django.utils.translation import gettext_lazy as _ class FinanceConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'finance' - verbose_name = _('Finance') + default_auto_field = "django.db.models.BigAutoField" + name = "finance" + verbose_name = _("Finance") diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index d0e6678..831a42c 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -1,39 +1,44 @@ -import math +import re from itertools import groupby -from decimal import Decimal, ROUND_HALF_DOWN -from django.utils import timezone -from .rules import is_creator, not_submitted, leads_excursion -from members.rules import is_leader, statement_not_submitted -from django.core.exceptions import ValidationError -from django.db import models -from django.db.models import Sum -from django.utils.translation import gettext_lazy as _ -from django.utils.html import format_html -from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE -from django.conf import settings import rules +from contrib.media import media_path from contrib.models import CommonModel from contrib.rules import has_global_perm -from utils import cvt_to_decimal, RestrictedFileField -from members.pdf import render_tex_with_attachments +from django.conf import settings +from django.db import models +from django.db.models import Sum +from django.utils import timezone +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ from mailer.mailutils import send as send_mail -from contrib.media import media_path - +from members.models import Freizeit +from members.models import Member +from members.models import MUSKELKRAFT_ANREISE +from members.models import OEFFENTLICHE_ANREISE +from members.pdf import render_tex_with_attachments +from members.rules import is_leader +from members.rules import statement_not_submitted from schwifty import IBAN -import re +from utils import cvt_to_decimal +from utils import RestrictedFileField + +from .rules import is_creator +from .rules import leads_excursion +from .rules import not_submitted # Create your models here. + class Ledger(models.Model): - name = models.CharField(verbose_name=_('Name'), max_length=30) + name = models.CharField(verbose_name=_("Name"), max_length=30) def __str__(self): return self.name class Meta: - verbose_name = _('Ledger') - verbose_name_plural = _('Ledgers') + verbose_name = _("Ledger") + verbose_name_plural = _("Ledgers") class TransactionIssue: @@ -51,88 +56,123 @@ class StatementManager(models.Manager): class Statement(CommonModel): - MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = 0, 1, 2, 3, 4 + MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = ( + 0, + 1, + 2, + 3, + 4, + ) UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2 - STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')), - (SUBMITTED, _('Submitted')), - (CONFIRMED, _('Completed'))] - STATUS_CSS_CLASS = { SUBMITTED: 'submitted', - CONFIRMED: 'confirmed', - UNSUBMITTED: 'unsubmitted' } - - short_description = models.CharField(verbose_name=_('Short description'), - max_length=30, - blank=False) - explanation = models.TextField(verbose_name=_('Explanation'), blank=True) - - excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'), - blank=True, - null=True, - on_delete=models.SET_NULL) - - allowance_to = models.ManyToManyField(Member, verbose_name=_('Pay allowance to'), - related_name='receives_allowance_for_statements', - blank=True, - help_text=_('The youth leaders to which an allowance should be paid.')) - subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'), - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='receives_subsidy_for_statements', - help_text=_('The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.')) - - ljp_to = models.ForeignKey(Member, verbose_name=_('Pay ljp contributions to'), - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='receives_ljp_for_statements', - help_text=_('The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted.')) - - night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5) - - status = models.IntegerField(verbose_name=_('Status'), - choices=STATUS_CHOICES, - default=UNSUBMITTED) - submitted_date = models.DateTimeField(verbose_name=_('Submitted on'), default=None, null=True) - confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True) - - created_by = models.ForeignKey(Member, verbose_name=_('Created by'), - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name='created_statements') - 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') + STATUS_CHOICES = [ + (UNSUBMITTED, _("In preparation")), + (SUBMITTED, _("Submitted")), + (CONFIRMED, _("Completed")), + ] + STATUS_CSS_CLASS = {SUBMITTED: "submitted", CONFIRMED: "confirmed", UNSUBMITTED: "unsubmitted"} + + short_description = models.CharField( + verbose_name=_("Short description"), max_length=30, blank=False + ) + explanation = models.TextField(verbose_name=_("Explanation"), blank=True) + + excursion = models.OneToOneField( + Freizeit, + verbose_name=_("Associated excursion"), + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + + allowance_to = models.ManyToManyField( + Member, + verbose_name=_("Pay allowance to"), + related_name="receives_allowance_for_statements", + blank=True, + help_text=_("The youth leaders to which an allowance should be paid."), + ) + subsidy_to = models.ForeignKey( + Member, + verbose_name=_("Pay subsidy to"), + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="receives_subsidy_for_statements", + help_text=_( + "The person that should receive the subsidy for night and travel costs. Typically the person who paid for them." + ), + ) + + ljp_to = models.ForeignKey( + Member, + verbose_name=_("Pay ljp contributions to"), + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="receives_ljp_for_statements", + help_text=_( + "The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted." + ), + ) + + night_cost = models.DecimalField( + verbose_name=_("Price per night"), default=0, decimal_places=2, max_digits=5 + ) + + status = models.IntegerField( + verbose_name=_("Status"), choices=STATUS_CHOICES, default=UNSUBMITTED + ) + submitted_date = models.DateTimeField(verbose_name=_("Submitted on"), default=None, null=True) + confirmed_date = models.DateTimeField(verbose_name=_("Paid on"), default=None, null=True) + + created_by = models.ForeignKey( + Member, + verbose_name=_("Created by"), + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="created_statements", + ) + 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(CommonModel.Meta): - verbose_name = _('Statement') - verbose_name_plural = _('Statements') - permissions = [ - ('may_edit_submitted_statements', 'Is allowed to edit submitted statements') - ] + verbose_name = _("Statement") + verbose_name_plural = _("Statements") + permissions = [("may_edit_submitted_statements", "Is allowed to edit submitted statements")] rules_permissions = { # All users may add draft statements. - 'add_obj': rules.is_staff, + "add_obj": rules.is_staff, # All users may view their own statements and statements of excursions they are responsible for. - 'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statement'), + "view_obj": is_creator + | leads_excursion + | has_global_perm("finance.view_global_statement"), # All users may change relevant (see above) draft statements. - 'change_obj': (not_submitted & (is_creator | leads_excursion)) | has_global_perm('finance.change_global_statement'), + "change_obj": (not_submitted & (is_creator | leads_excursion)) + | has_global_perm("finance.change_global_statement"), # All users may delete relevant (see above) draft statements. - 'delete_obj': not_submitted & (is_creator | leads_excursion | has_global_perm('finance.delete_global_statement')), + "delete_obj": not_submitted + & (is_creator | leads_excursion | has_global_perm("finance.delete_global_statement")), } @property def title(self): if self.excursion is not None: - return _('Excursion %(excursion)s') % {'excursion': str(self.excursion)} + return _("Excursion %(excursion)s") % {"excursion": str(self.excursion)} else: return self.short_description @@ -149,10 +189,13 @@ class Statement(CommonModel): def status_badge(self): code = Statement.STATUS_CSS_CLASS[self.status] - return format_html(f'{Statement.STATUS_CHOICES[self.status][1]}') - status_badge.short_description = _('Status') + return format_html( + f'{Statement.STATUS_CHOICES[self.status][1]}' + ) + + status_badge.short_description = _("Status") status_badge.allow_tags = True - status_badge.admin_order_field = 'status' + status_badge.admin_order_field = "status" def submit(self, submitter=None): self.status = self.SUBMITTED @@ -174,7 +217,9 @@ class Statement(CommonModel): total still differs from the transaction total.) - If the statement is associated with an excursion: allowances, subsidies, LJP paiment and org fee. """ - needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by] + needed_paiments = [ + (b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by + ] if self.excursion is not None: needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()]) @@ -189,10 +234,20 @@ class Statement(CommonModel): needed_paiments.append((self.ljp_to, self.paid_ljp_contributions)) needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk) - target = dict(map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0]))) + target = dict( + 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))) + 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.items(): @@ -274,8 +329,9 @@ class Statement(CommonModel): def is_valid(self): return self.validity == Statement.VALID + is_valid.boolean = True - is_valid.short_description = _('Ready to confirm') + is_valid.short_description = _("Ready to confirm") def confirm(self, confirmer=None): if not self.submitted: @@ -303,7 +359,13 @@ class Statement(CommonModel): if not bill.paid_by: return False ref = "{}: {}".format(str(self), bill.short_description) - Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save() + Transaction( + statement=self, + member=bill.paid_by, + amount=bill.amount, + confirmed=False, + reference=ref, + ).save() # excursion specific if self.excursion is None: @@ -311,23 +373,46 @@ class Statement(CommonModel): # allowance for yl in self.allowance_to.all(): - ref = _("Allowance for %(excu)s") % {'excu': self.excursion.name} - Transaction(statement=self, member=yl, amount=self.allowance_per_yl, confirmed=False, reference=ref).save() + ref = _("Allowance for %(excu)s") % {"excu": self.excursion.name} + Transaction( + statement=self, + member=yl, + amount=self.allowance_per_yl, + confirmed=False, + reference=ref, + ).save() # subsidies (i.e. night and transportation costs) if self.subsidy_to: - ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name} - Transaction(statement=self, member=self.subsidy_to, amount=self.total_subsidies, confirmed=False, reference=ref).save() + ref = _("Night and travel costs for %(excu)s") % {"excu": self.excursion.name} + Transaction( + statement=self, + member=self.subsidy_to, + amount=self.total_subsidies, + confirmed=False, + reference=ref, + ).save() if self.total_org_fee: # if no subsidy receiver is given but org fees have to be paid. Just pick one of allowance receivers ref = _("reduced by org fee") - Transaction(statement=self, member=self.org_fee_payant, amount=-self.total_org_fee, confirmed=False, reference=ref).save() + Transaction( + statement=self, + member=self.org_fee_payant, + amount=-self.total_org_fee, + confirmed=False, + reference=ref, + ).save() if self.ljp_to: - ref = _("LJP-Contribution %(excu)s") % {'excu': self.excursion.name} - Transaction(statement=self, member=self.ljp_to, amount=self.paid_ljp_contributions, - confirmed=False, reference=ref).save() + ref = _("LJP-Contribution %(excu)s") % {"excu": self.excursion.name} + Transaction( + statement=self, + member=self.ljp_to, + amount=self.paid_ljp_contributions, + confirmed=False, + reference=ref, + ).save() return True @@ -335,11 +420,15 @@ class Statement(CommonModel): # 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)): + 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) + def sort_key(trans): + return (trans.member.pk, trans.ledger.pk) + + def group_key(trans): + return (trans.member, trans.ledger) + transactions = sorted(transactions, key=sort_key) for pair, transaction_group in groupby(transactions, group_key): member, ledger = pair @@ -347,10 +436,16 @@ class Statement(CommonModel): if len(grp) == 1: continue - new_amount = sum((trans.amount for trans in grp)) - new_ref = ", ".join((f"{trans.reference} EUR{trans.amount: .2f}" for trans in grp)) - Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref, - ledger=ledger).save() + new_amount = sum(trans.amount for trans in grp) + new_ref = ", ".join(f"{trans.reference} EUR{trans.amount: .2f}" for trans in grp) + Transaction( + statement=self, + member=member, + amount=new_amount, + confirmed=False, + reference=new_ref, + ledger=ledger, + ).save() for trans in grp: trans.delete() @@ -367,7 +462,7 @@ class Statement(CommonModel): def bills_without_proof(self): """Returns the bills that lack a proof file""" return [bill for bill in self.bill_set.all() if not bill.proof] - + @property def total_bills_theoretic(self): return sum([bill.amount for bill in self.bill_set.all()]) @@ -382,8 +477,10 @@ class Statement(CommonModel): if self.excursion is None: return 0 - if self.excursion.tour_approach == MUSKELKRAFT_ANREISE \ - or self.excursion.tour_approach == OEFFENTLICHE_ANREISE: + if ( + self.excursion.tour_approach == MUSKELKRAFT_ANREISE + or self.excursion.tour_approach == OEFFENTLICHE_ANREISE + ): return 0.15 else: return 0.1 @@ -431,9 +528,7 @@ class Statement(CommonModel): @property def total_per_yl(self): - return self.transportation_per_yl \ - + self.allowance_per_yl \ - + self.nights_per_yl + return self.transportation_per_yl + self.allowance_per_yl + self.nights_per_yl @property def real_per_yl(self): @@ -447,7 +542,11 @@ class Statement(CommonModel): """participants older than 26.99 years need to pay a specified organisation fee per person per day.""" if self.excursion is None: return 0 - return cvt_to_decimal(settings.EXCURSION_ORG_FEE * self.excursion.duration * self.excursion.old_participant_count) + return cvt_to_decimal( + settings.EXCURSION_ORG_FEE + * self.excursion.duration + * self.excursion.old_participant_count + ) @property def total_org_fee(self): @@ -480,7 +579,7 @@ class Statement(CommonModel): @property def theoretical_total_staff(self): """ - the sum of subsidies and allowances if all eligible youth leaders would collect them. + the sum of subsidies and allowances if all eligible youth leaders would collect them. """ return self.total_per_yl * self.real_staff_count @@ -495,7 +594,6 @@ class Statement(CommonModel): def total_staff_paid(self): return self.total_staff - self.total_org_fee - @property def real_staff_count(self): if self.excursion is None: @@ -511,22 +609,26 @@ class Statement(CommonModel): return 0 else: return self.excursion.approved_staff_count - + @property def paid_ljp_contributions(self): - if hasattr(self.excursion, 'ljpproposal') and self.ljp_to: - + if hasattr(self.excursion, "ljpproposal") and self.ljp_to: if self.excursion.theoretic_ljp_participant_count < 5: return 0 - + return cvt_to_decimal( min( # if total costs are more than the max amount of the LJP contribution, we pay the max amount, reduced by taxes - (1-settings.LJP_TAX) * settings.LJP_CONTRIBUTION_PER_DAY * self.excursion.ljp_participant_count * self.excursion.ljp_duration, + (1 - settings.LJP_TAX) + * settings.LJP_CONTRIBUTION_PER_DAY + * self.excursion.ljp_participant_count + * self.excursion.ljp_duration, # if the total costs are less than the max amount, we pay up to 90% of the total costs, reduced by taxes - (1-settings.LJP_TAX) * 0.9 * (float(self.total_bills_not_covered) + float(self.total_staff) ), + (1 - settings.LJP_TAX) + * 0.9 + * (float(self.total_bills_not_covered) + float(self.total_staff)), # we never pay more than the maximum costs of the trip - float(self.total_bills_not_covered) + float(self.total_bills_not_covered), ) ) else: @@ -534,7 +636,7 @@ class Statement(CommonModel): @property def total(self): - return self.total_bills + self.total_staff_paid + self.paid_ljp_contributions + return self.total_bills + self.total_staff_paid + self.paid_ljp_contributions @property def total_theoretic(self): @@ -548,60 +650,61 @@ class Statement(CommonModel): def total_pretty(self): return "{}€".format(self.total) - total_pretty.short_description = _('Total') - total_pretty.admin_order_field = 'total' + + total_pretty.short_description = _("Total") + total_pretty.admin_order_field = "total" def template_context(self): context = { - 'total_bills': self.total_bills, - 'total_bills_theoretic': self.total_bills_theoretic, - 'bills_covered': self.bills_covered, - 'total': self.total, + "total_bills": self.total_bills, + "total_bills_theoretic": self.total_bills_theoretic, + "bills_covered": self.bills_covered, + "total": self.total, } if self.excursion: excursion_context = { - 'nights': self.excursion.night_count, - 'price_per_night': self.real_night_cost, - 'duration': self.excursion.duration, - 'staff_count': self.real_staff_count, - 'kilometers_traveled': self.excursion.kilometers_traveled, - 'means_of_transport': self.excursion.get_tour_approach(), - 'euro_per_km': self.euro_per_km, - 'allowance_per_day': settings.ALLOWANCE_PER_DAY, - 'allowances_paid': self.allowances_paid, - 'nights_per_yl': self.nights_per_yl, - 'allowance_per_yl': self.allowance_per_yl, - 'total_allowance': self.total_allowance, - 'transportation_per_yl': self.transportation_per_yl, - 'total_per_yl': self.total_per_yl, - 'total_staff': self.total_staff, - 'total_allowance': self.total_allowance, - 'theoretical_total_staff': self.theoretical_total_staff, - 'real_staff_count': self.real_staff_count, - 'total_subsidies': self.total_subsidies, - 'total_allowance': self.total_allowance, - 'subsidy_to': self.subsidy_to, - 'allowance_to': self.allowance_to, - 'paid_ljp_contributions': self.paid_ljp_contributions, - 'ljp_to': self.ljp_to, - 'theoretic_ljp_participant_count': self.excursion.theoretic_ljp_participant_count, - 'participant_count': self.excursion.participant_count, - 'total_seminar_days': self.excursion.total_seminar_days, - 'ljp_tax': settings.LJP_TAX * 100, - 'total_org_fee_theoretical': self.total_org_fee_theoretical, - 'total_org_fee': self.total_org_fee, - 'old_participant_count': self.excursion.old_participant_count, - 'total_staff_paid': self.total_staff_paid, - 'org_fee': cvt_to_decimal(settings.EXCURSION_ORG_FEE), + "nights": self.excursion.night_count, + "price_per_night": self.real_night_cost, + "duration": self.excursion.duration, + "staff_count": self.real_staff_count, + "kilometers_traveled": self.excursion.kilometers_traveled, + "means_of_transport": self.excursion.get_tour_approach(), + "euro_per_km": self.euro_per_km, + "allowance_per_day": settings.ALLOWANCE_PER_DAY, + "allowances_paid": self.allowances_paid, + "nights_per_yl": self.nights_per_yl, + "allowance_per_yl": self.allowance_per_yl, + "total_allowance": self.total_allowance, + "transportation_per_yl": self.transportation_per_yl, + "total_per_yl": self.total_per_yl, + "total_staff": self.total_staff, + "theoretical_total_staff": self.theoretical_total_staff, + "real_staff_count": self.real_staff_count, + "total_subsidies": self.total_subsidies, + "subsidy_to": self.subsidy_to, + "allowance_to": self.allowance_to, + "paid_ljp_contributions": self.paid_ljp_contributions, + "ljp_to": self.ljp_to, + "theoretic_ljp_participant_count": self.excursion.theoretic_ljp_participant_count, + "participant_count": self.excursion.participant_count, + "total_seminar_days": self.excursion.total_seminar_days, + "ljp_tax": settings.LJP_TAX * 100, + "total_org_fee_theoretical": self.total_org_fee_theoretical, + "total_org_fee": self.total_org_fee, + "old_participant_count": self.excursion.old_participant_count, + "total_staff_paid": self.total_staff_paid, + "org_fee": cvt_to_decimal(settings.EXCURSION_ORG_FEE), } return dict(context, **excursion_context) else: return context def grouped_bills(self): - return self.bill_set.values('short_description')\ - .order_by('short_description')\ - .annotate(amount=Sum('amount')) + return ( + self.bill_set.values("short_description") + .order_by("short_description") + .annotate(amount=Sum("amount")) + ) def send_summary(self, cc=None): """ @@ -609,29 +712,34 @@ class Statement(CommonModel): """ excursion = self.excursion context = dict(statement=self.template_context(), excursion=excursion, settings=settings) - pdf_filename = f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else f"Abrechnungsbeleg" + pdf_filename = ( + f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else "Abrechnungsbeleg" + ) attachments = [bill.proof.path for bill in self.bills_covered if bill.proof] - filename = render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex', - context, attachments, save_only=True) - send_mail(_('Statement summary for %(title)s') % { 'title': self.title }, - settings.SEND_STATEMENT_SUMMARY.format(statement=self.title), - sender=settings.DEFAULT_SENDING_MAIL, - recipients=[settings.SEKTION_FINANCE_MAIL], - cc=cc, - attachments=[media_path(filename)]) + filename = render_tex_with_attachments( + pdf_filename, "finance/statement_summary.tex", context, attachments, save_only=True + ) + send_mail( + _("Statement summary for %(title)s") % {"title": self.title}, + settings.SEND_STATEMENT_SUMMARY.format(statement=self.title), + sender=settings.DEFAULT_SENDING_MAIL, + recipients=[settings.SEKTION_FINANCE_MAIL], + cc=cc, + attachments=[media_path(filename)], + ) class StatementOnExcursionProxy(Statement): class Meta(CommonModel.Meta): proxy = True - verbose_name = _('Statement') - verbose_name_plural = _('Statements') + verbose_name = _("Statement") + verbose_name_plural = _("Statements") rules_permissions = { # This is used as an inline on excursions, so we check for excursion permissions. - 'add_obj': is_leader, - 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), - 'change_obj': is_leader & statement_not_submitted, - 'delete_obj': is_leader & statement_not_submitted, + "add_obj": is_leader, + "view_obj": is_leader | has_global_perm("members.view_global_freizeit"), + "change_obj": is_leader & statement_not_submitted, + "delete_obj": is_leader & statement_not_submitted, } @@ -645,13 +753,15 @@ class StatementUnSubmitted(Statement): class Meta(CommonModel.Meta): proxy = True - verbose_name = _('Statement in preparation') - verbose_name_plural = _('Statements in preparation') + verbose_name = _("Statement in preparation") + verbose_name_plural = _("Statements in preparation") rules_permissions = { - 'add_obj': rules.is_staff, - 'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statementunsubmitted'), - 'change_obj': is_creator | leads_excursion, - 'delete_obj': is_creator | leads_excursion, + "add_obj": rules.is_staff, + "view_obj": is_creator + | leads_excursion + | has_global_perm("finance.view_global_statementunsubmitted"), + "change_obj": is_creator | leads_excursion, + "delete_obj": is_creator | leads_excursion, } @@ -665,10 +775,10 @@ class StatementSubmitted(Statement): class Meta(CommonModel.Meta): proxy = True - verbose_name = _('Submitted statement') - verbose_name_plural = _('Submitted statements') + verbose_name = _("Submitted statement") + verbose_name_plural = _("Submitted statements") permissions = [ - ('process_statementsubmitted', 'Can manage submitted statements.'), + ("process_statementsubmitted", "Can manage submitted statements."), ] @@ -682,111 +792,135 @@ class StatementConfirmed(Statement): class Meta(CommonModel.Meta): proxy = True - verbose_name = _('Paid statement') - verbose_name_plural = _('Paid statements') + verbose_name = _("Paid statement") + verbose_name_plural = _("Paid statements") permissions = [ - ('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'), + ("may_manage_confirmed_statements", "Can view and manage confirmed statements."), ] class Bill(CommonModel): - statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) - short_description = models.CharField(verbose_name=_('Short description'), max_length=30, blank=False) - explanation = models.TextField(verbose_name=_('Explanation'), blank=True) - - amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0) - paid_by = models.ForeignKey(Member, verbose_name=_('Paid 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) - - proof = RestrictedFileField(verbose_name=_('Proof'), - upload_to='bill_images', - blank=True, - max_upload_size=5, - content_types=['application/pdf', - 'image/jpeg', - 'image/png', - 'image/gif']) + statement = models.ForeignKey(Statement, verbose_name=_("Statement"), on_delete=models.CASCADE) + short_description = models.CharField( + verbose_name=_("Short description"), max_length=30, blank=False + ) + explanation = models.TextField(verbose_name=_("Explanation"), blank=True) + + amount = models.DecimalField( + verbose_name=_("Amount"), max_digits=6, decimal_places=2, default=0 + ) + paid_by = models.ForeignKey( + Member, verbose_name=_("Paid 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) + + proof = RestrictedFileField( + verbose_name=_("Proof"), + upload_to="bill_images", + blank=True, + max_upload_size=5, + content_types=["application/pdf", "image/jpeg", "image/png", "image/gif"], + ) def __str__(self): return "{} ({}€)".format(self.short_description, self.amount) def pretty_amount(self): return "{}€".format(self.amount) - pretty_amount.admin_order_field = 'amount' - pretty_amount.short_description = _('Amount') + + pretty_amount.admin_order_field = "amount" + pretty_amount.short_description = _("Amount") class Meta(CommonModel.Meta): - verbose_name = _('Bill') - verbose_name_plural = _('Bills') + verbose_name = _("Bill") + verbose_name_plural = _("Bills") class BillOnExcursionProxy(Bill): class Meta(CommonModel.Meta): proxy = True - verbose_name = _('Bill') - verbose_name_plural = _('Bills') + verbose_name = _("Bill") + verbose_name_plural = _("Bills") rules_permissions = { - 'add_obj': leads_excursion & not_submitted, - 'view_obj': leads_excursion | has_global_perm('finance.view_global_billonexcursionproxy'), - 'change_obj': (leads_excursion | has_global_perm('finance.change_global_billonexcursionproxy')) & not_submitted, - 'delete_obj': (leads_excursion | has_global_perm('finance.delete_global_billonexcursionproxy')) & not_submitted, + "add_obj": leads_excursion & not_submitted, + "view_obj": leads_excursion + | has_global_perm("finance.view_global_billonexcursionproxy"), + "change_obj": ( + leads_excursion | has_global_perm("finance.change_global_billonexcursionproxy") + ) + & not_submitted, + "delete_obj": ( + leads_excursion | has_global_perm("finance.delete_global_billonexcursionproxy") + ) + & not_submitted, } class BillOnStatementProxy(Bill): class Meta(CommonModel.Meta): proxy = True - verbose_name = _('Bill') - verbose_name_plural = _('Bills') + verbose_name = _("Bill") + verbose_name_plural = _("Bills") rules_permissions = { - 'add_obj': (is_creator | leads_excursion) & not_submitted, - 'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_billonstatementproxy'), - 'change_obj': (is_creator | leads_excursion | has_global_perm('finance.change_global_billonstatementproxy')) - & (not_submitted | has_global_perm('finance.process_statementsubmitted')), - 'delete_obj': (is_creator | leads_excursion | has_global_perm('finance.delete_global_billonstatementproxy')) - & not_submitted, + "add_obj": (is_creator | leads_excursion) & not_submitted, + "view_obj": is_creator + | leads_excursion + | has_global_perm("finance.view_global_billonstatementproxy"), + "change_obj": ( + is_creator + | leads_excursion + | has_global_perm("finance.change_global_billonstatementproxy") + ) + & (not_submitted | has_global_perm("finance.process_statementsubmitted")), + "delete_obj": ( + is_creator + | leads_excursion + | has_global_perm("finance.delete_global_billonstatementproxy") + ) + & not_submitted, } class Transaction(models.Model): - reference = models.TextField(verbose_name=_('Reference')) - amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_('Amount')) - 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=_('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') + reference = models.TextField(verbose_name=_("Reference")) + amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_("Amount")) + 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=_("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) @staticmethod def escape_reference(reference): - umlaut_map = { - 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', - 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', - 'ß': 'ss' - } - pattern = re.compile('|'.join(umlaut_map.keys())) + umlaut_map = {"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"} + pattern = re.compile("|".join(umlaut_map.keys())) int_reference = pattern.sub(lambda x: umlaut_map[x.group()], reference) allowed_chars = r"[^a-z0-9 /?: .,'+-]" - clean_reference = re.sub(allowed_chars, '', int_reference, flags=re.IGNORECASE) + clean_reference = re.sub(allowed_chars, "", int_reference, flags=re.IGNORECASE) return clean_reference def code(self): - if self.amount == 0: return "" @@ -796,7 +930,7 @@ class Transaction(models.Model): bic = iban.bic reference = self.escape_reference(self.reference) - + # also escaping receiver as umlaute are also not allowed here receiver = self.escape_reference(f"{self.member.prename} {self.member.lastname}") return f"""BCD @@ -812,13 +946,14 @@ EUR{self.amount} {reference}""" class Meta: - verbose_name = _('Transaction') - verbose_name_plural = _('Transactions') + verbose_name = _("Transaction") + verbose_name_plural = _("Transactions") class Receipt(models.Model): - short_description = models.CharField(verbose_name=_('Short description'), max_length=30) - ledger = models.ForeignKey(Ledger, blank=False, null=False, verbose_name=_('Ledger'), - on_delete=models.CASCADE) + short_description = models.CharField(verbose_name=_("Short description"), max_length=30) + ledger = models.ForeignKey( + Ledger, blank=False, null=False, verbose_name=_("Ledger"), on_delete=models.CASCADE + ) amount = models.DecimalField(max_digits=6, decimal_places=2) comments = models.TextField() diff --git a/jdav_web/finance/rules.py b/jdav_web/finance/rules.py index bb7e955..0e6f73d 100644 --- a/jdav_web/finance/rules.py +++ b/jdav_web/finance/rules.py @@ -1,7 +1,7 @@ -from members.models import Freizeit from contrib.rules import memberize_user -from rules import predicate +from members.models import Freizeit from members.rules import _is_leader +from rules import predicate @predicate @@ -16,7 +16,7 @@ def is_creator(self, statement): def not_submitted(self, statement): assert statement is not None if isinstance(statement, Freizeit): - if hasattr(statement, 'statement'): + if hasattr(statement, "statement"): return not statement.statement.submitted else: return True @@ -29,7 +29,7 @@ def leads_excursion(self, statement): assert statement is not None if isinstance(statement, Freizeit): return _is_leader(self, statement) - if not hasattr(statement, 'excursion'): + if not hasattr(statement, "excursion"): return False if statement.excursion is None: return False