chore(finance/*): reformat using ruff (#16)

mk-personal-profile
Christian Merten 2 weeks ago committed by GitHub
parent 9ffb6b33bb
commit 1c0744811b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,40 +1,48 @@
import logging 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 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 contrib.admin import CommonAdminInlineMixin
from utils import get_member, RestrictedFileField from contrib.admin import CommonAdminMixin
from django import forms
from rules.contrib.admin import ObjectPermissionsModelAdmin 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 members.pdf import render_tex_with_attachments
from utils import get_member
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ from .models import Bill
StatementUnSubmitted, BillOnStatementProxy 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__) logger = logging.getLogger(__name__)
@admin.register(Ledger) @admin.register(Ledger)
class LedgerAdmin(admin.ModelAdmin): class LedgerAdmin(admin.ModelAdmin):
search_fields = ('name', ) search_fields = ("name",)
class BillOnStatementInlineForm(forms.ModelForm): class BillOnStatementInlineForm(forms.ModelForm):
class Meta: class Meta:
model = BillOnStatementProxy model = BillOnStatementProxy
fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] fields = ["short_description", "explanation", "amount", "paid_by", "proof"]
widgets = { widgets = {
'proof': ClearableFileInput(attrs={'accept': 'application/pdf,image/jpeg,image/png'}), "proof": ClearableFileInput(attrs={"accept": "application/pdf,image/jpeg,image/png"}),
'explanation': Textarea(attrs={'rows': 1, 'cols': 40}) "explanation": Textarea(attrs={"rows": 1, "cols": 40}),
} }
@ -51,24 +59,38 @@ def decorate_statement_view(model, perm=None):
try: try:
statement = model.objects.get(pk=object_id) statement = model.objects.get(pk=object_id)
except model.DoesNotExist: except model.DoesNotExist:
messages.error(request, _('Statement not found.')) messages.error(request, _("Statement not found."))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) return HttpResponseRedirect(
permitted = self.has_change_permission(request, statement) if not perm else request.user.has_perm(perm) 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: if not permitted:
messages.error(request, _('Insufficient permissions.')) messages.error(request, _("Insufficient permissions."))
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)
)
)
return fun(self, request, statement) return fun(self, request, statement)
return aux return aux
return decorator return decorator
@admin.register(Statement) @admin.register(Statement)
class StatementAdmin(CommonAdminMixin, admin.ModelAdmin): class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'status'] fields = ["short_description", "explanation", "excursion", "status"]
list_display = ['__str__', 'total_pretty', 'created_by', 'submitted_date', 'status_badge'] list_display = ["__str__", "total_pretty", "created_by", "submitted_date", "status_badge"]
list_filter = ['status'] list_filter = ["status"]
search_fields = ('excursion__name', 'short_description') search_fields = ("excursion__name", "short_description")
ordering = ['-submitted_date'] ordering = ["-submitted_date"]
inlines = [BillOnStatementInline] inlines = [BillOnStatementInline]
list_per_page = 25 list_per_page = 25
@ -87,7 +109,7 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
return super().has_delete_permission(request, obj) return super().has_delete_permission(request, obj)
def save_model(self, request, obj, form, change): 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 obj.created_by = request.user.member
super().save_model(request, obj, form, change) 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 obj is not None and obj.excursion:
# if the object exists and an excursion is set, show the excursion (read only) # if the object exists and an excursion is set, show the excursion (read only)
# instead of the short description # instead of the short description
return ['excursion', 'explanation', 'status'] return ["excursion", "explanation", "status"]
else: else:
# if the object is newly created or no excursion is set, require # if the object is newly created or no excursion is set, require
# a short description # a short description
return ['short_description', 'explanation', 'status'] return ["short_description", "explanation", "status"]
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
readonly_fields = ['status', 'excursion'] readonly_fields = ["status", "excursion"]
if obj is not None and obj.submitted: if obj is not None and obj.submitted:
return readonly_fields + self.fields return readonly_fields + self.fields
else: else:
@ -128,208 +150,338 @@ class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
path( path(
"<path:object_id>/submit/", "<path:object_id>/submit/",
wrap(self.submit_view), 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( path(
"<path:object_id>/overview/", "<path:object_id>/overview/",
wrap(self.overview_view), 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( path(
"<path:object_id>/reduce_transactions/", "<path:object_id>/reduce_transactions/",
wrap(self.reduce_transactions_view), 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( path(
"<path:object_id>/unconfirm/", "<path:object_id>/unconfirm/",
wrap(self.unconfirm_view), 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( path(
"<path:object_id>/summary/", "<path:object_id>/summary/",
wrap(self.statement_summary_view), 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 return custom_urls + urls
@decorate_statement_view(StatementUnSubmitted) @decorate_statement_view(StatementUnSubmitted)
def submit_view(self, request, statement): def submit_view(self, request, statement):
if statement.submitted: # pragma: no cover if statement.submitted: # pragma: no cover
logger.error(f"submit_view reached with submitted statement {statement}. This should not happen.") logger.error(
messages.error(request, f"submit_view reached with submitted statement {statement}. This should not happen."
_("%(name)s is already submitted.") % {'name': str(statement)}) )
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) 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: if "apply" in request.POST:
statement.submit(get_member(request)) statement.submit(get_member(request))
messages.success(request, messages.success(
_("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)}) request,
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) _(
"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: if statement.excursion:
memberlist = statement.excursion memberlist = statement.excursion
context = dict(self.admin_site.each_context(request), context = dict(
title=_('Finance overview'), self.admin_site.each_context(request),
opts=self.opts, title=_("Finance overview"),
memberlist=memberlist, opts=self.opts,
object=memberlist, memberlist=memberlist,
ljp_contributions=memberlist.payable_ljp_contributions, object=memberlist,
total_relative_costs=memberlist.total_relative_costs, ljp_contributions=memberlist.payable_ljp_contributions,
**memberlist.statement.template_context()) total_relative_costs=memberlist.total_relative_costs,
return render(request, 'admin/freizeit_finance_overview.html', context=context) **memberlist.statement.template_context(),
)
return render(request, "admin/freizeit_finance_overview.html", context=context)
else: else:
context = dict(self.admin_site.each_context(request), context = dict(
title=_('Submit statement'), self.admin_site.each_context(request),
title=_("Submit statement"),
opts=self.opts, opts=self.opts,
statement=statement) statement=statement,
return render(request, 'admin/submit_statement.html', context=context) )
return render(request, "admin/submit_statement.html", context=context)
@decorate_statement_view(StatementSubmitted) @decorate_statement_view(StatementSubmitted)
def overview_view(self, request, statement): def overview_view(self, request, statement):
if not statement.submitted: # pragma: no cover if not statement.submitted: # pragma: no cover
logger.error(f"overview_view reached with unsubmitted statement {statement}. This should not happen.") logger.error(
messages.error(request, f"overview_view reached with unsubmitted statement {statement}. This should not happen."
_("%(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,))) messages.error(request, _("%(name)s is not yet submitted.") % {"name": str(statement)})
if "transaction_execution_confirm" in request.POST or "transaction_execution_confirm_and_send" in request.POST: 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)) res = statement.confirm(confirmer=get_member(request))
if not res: # pragma: no cover if not res: # pragma: no cover
# this should NOT happen! # this should NOT happen!
logger.error(f"Error occured while confirming {statement}, this should not be possible.") logger.error(
messages.error(request, f"Error occured while confirming {statement}, this should not be possible."
_("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.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: 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 sent receipt to the office."))
messages.success(request, messages.success(
_("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") request,
% {'name': str(statement)}) _(
download_link = reverse('admin:%s_%s_summary' % (self.opts.app_label, self.opts.model_name), "Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again."
args=(statement.pk,)) )
messages.success(request, % {"name": str(statement)},
mark_safe(_("You can download a <a href='%(link)s', target='_blank'>receipt</a>.") % {'link': download_link})) )
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) 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 <a href='%(link)s', target='_blank'>receipt</a>.")
% {"link": download_link}
),
)
return HttpResponseRedirect(
reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
)
if "confirm" in request.POST: if "confirm" in request.POST:
res = statement.validity res = statement.validity
if res == Statement.VALID: if res == Statement.VALID:
context = dict(self.admin_site.each_context(request), context = dict(
title=_('Statement confirmed'), self.admin_site.each_context(request),
opts=self.opts, title=_("Statement confirmed"),
statement=statement) opts=self.opts,
return render(request, 'admin/confirmed_statement.html', context=context) statement=statement,
)
return render(request, "admin/confirmed_statement.html", context=context)
elif res == Statement.NON_MATCHING_TRANSACTIONS: elif res == Statement.NON_MATCHING_TRANSACTIONS:
messages.error(request, messages.error(
_("Transactions do not match the covered expenses. Please correct the mistakes listed below.") request,
% {'name': str(statement)}) _(
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) "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: elif res == Statement.MISSING_LEDGER:
messages.error(request, messages.error(
_("Some transactions have no ledger configured. Please fill in the gaps.") request,
% {'name': str(statement)}) _("Some transactions have no ledger configured. Please fill in the gaps.")
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) % {"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: elif res == Statement.INVALID_ALLOWANCE_TO:
messages.error(request, messages.error(
_("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion.")) request,
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 "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}.") logger.error(f"INVALID_TOTAL reached on {statement}.")
messages.error(request, messages.error(
_("The calculated total amount does not match the sum of all transactions. This is most likely a bug.")) request,
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) _(
else: # pragma: no cover "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}.") 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: if "reject" in request.POST:
statement.status = Statement.UNSUBMITTED statement.status = Statement.UNSUBMITTED
statement.save() statement.save()
messages.success(request, messages.success(
_("Successfully rejected %(name)s. The requestor can reapply, when needed.") request,
% {'name': str(statement)}) _("Successfully rejected %(name)s. The requestor can reapply, when needed.")
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) % {"name": str(statement)},
)
return HttpResponseRedirect(
reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
)
if "generate_transactions" in request.POST: if "generate_transactions" in request.POST:
if statement.transaction_set.count() > 0: if statement.transaction_set.count() > 0:
messages.error(request, messages.error(
_("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(statement)}) request,
_(
"%(name)s already has transactions. Please delete them first, if you want to generate new ones"
)
% {"name": str(statement)},
)
else: else:
success = statement.generate_transactions() success = statement.generate_transactions()
if success: if success:
messages.success(request, messages.success(
_("Successfully generated transactions for %(name)s") % {'name': str(statement)}) request,
_("Successfully generated transactions for %(name)s")
% {"name": str(statement)},
)
else: else:
messages.error(request, messages.error(
_("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)}) request,
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), "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?"
title=_('View submitted statement'), )
opts=self.opts, % {"name": str(statement)},
statement=statement, )
settings=settings, return HttpResponseRedirect(
transaction_issues=statement.transaction_issues, reverse(
**statement.template_context()) "admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
return render(request, 'admin/overview_submitted_statement.html', context=context) )
)
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) @decorate_statement_view(StatementSubmitted)
def reduce_transactions_view(self, request, statement): def reduce_transactions_view(self, request, statement):
statement.reduce_transactions() statement.reduce_transactions()
messages.success(request, messages.success(
_("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) request, _("Successfully reduced transactions for %(name)s.") % {"name": str(statement)}
return HttpResponseRedirect(request.GET['redirectTo']) )
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): 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}.") logger.error(f"unconfirm_view reached with unconfirmed statement {statement}.")
messages.error(request, messages.error(request, _("%(name)s is not yet confirmed.") % {"name": str(statement)})
_("%(name)s is not yet confirmed.") % {'name': str(statement)}) return HttpResponseRedirect(
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) reverse(
"admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
if "unconfirm" in request.POST: if "unconfirm" in request.POST:
statement.status = Statement.SUBMITTED statement.status = Statement.SUBMITTED
statement.confirmed_date = None statement.confirmed_date = None
statement.confired_by = None statement.confired_by = None
statement.save() statement.save()
messages.success(request, messages.success(
_("Successfully unconfirmed %(name)s. I hope you know what you are doing.") request,
% {'name': str(statement)}) _("Successfully unconfirmed %(name)s. I hope you know what you are doing.")
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) % {"name": str(statement)},
)
context = dict(self.admin_site.each_context(request), return HttpResponseRedirect(
title=_('Unconfirm statement'), reverse("admin:{}_{}_changelist".format(self.opts.app_label, self.opts.model_name))
opts=self.opts, )
statement=statement)
context = dict(
return render(request, 'admin/unconfirm_statement.html', context=context) self.admin_site.each_context(request),
title=_("Unconfirm statement"),
@decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') 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): 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}.") logger.error(f"statement_summary_view reached with unconfirmed statement {statement}.")
messages.error(request, messages.error(request, _("%(name)s is not yet confirmed.") % {"name": str(statement)})
_("%(name)s is not yet confirmed.") % {'name': str(statement)}) return HttpResponseRedirect(
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) reverse(
"admin:{}_{}_change".format(self.opts.app_label, self.opts.model_name),
args=(statement.pk,),
)
)
excursion = statement.excursion 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] 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): class TransactionOnSubmittedStatementInline(admin.TabularInline):
model = Transaction model = Transaction
fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger'] fields = ["amount", "member", "reference", "text_length_warning", "ledger"]
formfield_overrides = { formfield_overrides = {TextField: {"widget": Textarea(attrs={"rows": 1, "cols": 40})}}
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} readonly_fields = ["text_length_warning"]
}
readonly_fields = ['text_length_warning']
extra = 0 extra = 0
def text_length_warning(self, obj): def text_length_warning(self, obj):
@ -340,6 +492,7 @@ class TransactionOnSubmittedStatementInline(admin.TabularInline):
return mark_safe(f'<span style="color: red;">{len_string}</span>') return mark_safe(f'<span style="color: red;">{len_string}</span>')
return len_string return len_string
text_length_warning.short_description = _("Length") text_length_warning.short_description = _("Length")
@ -347,13 +500,11 @@ class BillOnSubmittedStatementInline(BillOnStatementInline):
model = BillOnStatementProxy model = BillOnStatementProxy
extra = 0 extra = 0
sortable_options = [] sortable_options = []
fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] fields = ["short_description", "explanation", "amount", "paid_by", "proof", "costs_covered"]
formfield_overrides = { formfield_overrides = {TextField: {"widget": Textarea(attrs={"rows": 1, "cols": 40})}}
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})}
}
def get_readonly_fields(self, request, obj=None): 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) @admin.register(Transaction)
@ -361,16 +512,25 @@ class TransactionAdmin(admin.ModelAdmin):
"""The transaction admin site. This is only used to display transactions. All editing """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 is disabled on this site. All transactions should be changed on the respective statement
at the correct stage of the approval chain.""" at the correct stage of the approval chain."""
list_display = ['member', 'ledger', 'amount', 'reference', 'statement', 'confirmed',
'confirmed_date', 'confirmed_by'] list_display = [
list_filter = ('ledger', 'member', 'statement', 'confirmed') "member",
search_fields = ('reference', ) "ledger",
fields = ['reference', 'amount', 'member', 'ledger', 'statement'] "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): def get_readonly_fields(self, request, obj=None):
if obj is not None and obj.confirmed: if obj is not None and obj.confirmed:
return self.fields 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): def has_add_permission(self, request, obj=None):
# To preserve integrity, no one is allowed to add transactions # To preserve integrity, no one is allowed to add transactions
@ -387,6 +547,6 @@ class TransactionAdmin(admin.ModelAdmin):
@admin.register(Bill) @admin.register(Bill)
class BillAdmin(admin.ModelAdmin): class BillAdmin(admin.ModelAdmin):
list_display = ['__str__', 'statement', 'explanation', 'pretty_amount', 'paid_by', 'refunded'] list_display = ["__str__", "statement", "explanation", "pretty_amount", "paid_by", "refunded"]
list_filter = ('statement', 'paid_by', 'refunded') list_filter = ("statement", "paid_by", "refunded")
search_fields = ('reference', 'statement') search_fields = ("reference", "statement")

@ -3,6 +3,6 @@ from django.utils.translation import gettext_lazy as _
class FinanceConfig(AppConfig): class FinanceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'finance' name = "finance"
verbose_name = _('Finance') verbose_name = _("Finance")

@ -1,39 +1,44 @@
import math import re
from itertools import groupby 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 import rules
from contrib.media import media_path
from contrib.models import CommonModel from contrib.models import CommonModel
from contrib.rules import has_global_perm from contrib.rules import has_global_perm
from utils import cvt_to_decimal, RestrictedFileField from django.conf import settings
from members.pdf import render_tex_with_attachments 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 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 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. # Create your models here.
class Ledger(models.Model): 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): def __str__(self):
return self.name return self.name
class Meta: class Meta:
verbose_name = _('Ledger') verbose_name = _("Ledger")
verbose_name_plural = _('Ledgers') verbose_name_plural = _("Ledgers")
class TransactionIssue: class TransactionIssue:
@ -51,88 +56,123 @@ class StatementManager(models.Manager):
class Statement(CommonModel): 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 UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')), STATUS_CHOICES = [
(SUBMITTED, _('Submitted')), (UNSUBMITTED, _("In preparation")),
(CONFIRMED, _('Completed'))] (SUBMITTED, _("Submitted")),
STATUS_CSS_CLASS = { SUBMITTED: 'submitted', (CONFIRMED, _("Completed")),
CONFIRMED: 'confirmed', ]
UNSUBMITTED: 'unsubmitted' } STATUS_CSS_CLASS = {SUBMITTED: "submitted", CONFIRMED: "confirmed", UNSUBMITTED: "unsubmitted"}
short_description = models.CharField(verbose_name=_('Short description'), short_description = models.CharField(
max_length=30, verbose_name=_("Short description"), max_length=30, blank=False
blank=False) )
explanation = models.TextField(verbose_name=_('Explanation'), blank=True) explanation = models.TextField(verbose_name=_("Explanation"), blank=True)
excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'), excursion = models.OneToOneField(
blank=True, Freizeit,
null=True, verbose_name=_("Associated excursion"),
on_delete=models.SET_NULL) blank=True,
null=True,
allowance_to = models.ManyToManyField(Member, verbose_name=_('Pay allowance to'), on_delete=models.SET_NULL,
related_name='receives_allowance_for_statements', )
blank=True,
help_text=_('The youth leaders to which an allowance should be paid.')) allowance_to = models.ManyToManyField(
subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'), Member,
null=True, verbose_name=_("Pay allowance to"),
blank=True, related_name="receives_allowance_for_statements",
on_delete=models.SET_NULL, blank=True,
related_name='receives_subsidy_for_statements', help_text=_("The youth leaders to which an allowance should be paid."),
help_text=_('The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.')) )
subsidy_to = models.ForeignKey(
ljp_to = models.ForeignKey(Member, verbose_name=_('Pay ljp contributions to'), Member,
null=True, verbose_name=_("Pay subsidy to"),
blank=True, null=True,
on_delete=models.SET_NULL, blank=True,
related_name='receives_ljp_for_statements', on_delete=models.SET_NULL,
help_text=_('The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted.')) related_name="receives_subsidy_for_statements",
help_text=_(
night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5) "The person that should receive the subsidy for night and travel costs. Typically the person who paid for them."
),
status = models.IntegerField(verbose_name=_('Status'), )
choices=STATUS_CHOICES,
default=UNSUBMITTED) ljp_to = models.ForeignKey(
submitted_date = models.DateTimeField(verbose_name=_('Submitted on'), default=None, null=True) Member,
confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True) verbose_name=_("Pay ljp contributions to"),
null=True,
created_by = models.ForeignKey(Member, verbose_name=_('Created by'), blank=True,
blank=True, on_delete=models.SET_NULL,
null=True, related_name="receives_ljp_for_statements",
on_delete=models.SET_NULL, help_text=_(
related_name='created_statements') "The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted."
submitted_by = models.ForeignKey(Member, verbose_name=_('Submitted by'), ),
blank=True, )
null=True,
on_delete=models.SET_NULL, night_cost = models.DecimalField(
related_name='submitted_statements') verbose_name=_("Price per night"), default=0, decimal_places=2, max_digits=5
confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'), )
blank=True,
null=True, status = models.IntegerField(
on_delete=models.SET_NULL, verbose_name=_("Status"), choices=STATUS_CHOICES, default=UNSUBMITTED
related_name='confirmed_statements') )
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): class Meta(CommonModel.Meta):
verbose_name = _('Statement') verbose_name = _("Statement")
verbose_name_plural = _('Statements') verbose_name_plural = _("Statements")
permissions = [ permissions = [("may_edit_submitted_statements", "Is allowed to edit submitted statements")]
('may_edit_submitted_statements', 'Is allowed to edit submitted statements')
]
rules_permissions = { rules_permissions = {
# All users may add draft statements. # 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. # 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. # 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. # 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 @property
def title(self): def title(self):
if self.excursion is not None: if self.excursion is not None:
return _('Excursion %(excursion)s') % {'excursion': str(self.excursion)} return _("Excursion %(excursion)s") % {"excursion": str(self.excursion)}
else: else:
return self.short_description return self.short_description
@ -149,10 +189,13 @@ class Statement(CommonModel):
def status_badge(self): def status_badge(self):
code = Statement.STATUS_CSS_CLASS[self.status] code = Statement.STATUS_CSS_CLASS[self.status]
return format_html(f'<span class="statement-{code}">{Statement.STATUS_CHOICES[self.status][1]}</span>') return format_html(
status_badge.short_description = _('Status') f'<span class="statement-{code}">{Statement.STATUS_CHOICES[self.status][1]}</span>'
)
status_badge.short_description = _("Status")
status_badge.allow_tags = True status_badge.allow_tags = True
status_badge.admin_order_field = 'status' status_badge.admin_order_field = "status"
def submit(self, submitter=None): def submit(self, submitter=None):
self.status = self.SUBMITTED self.status = self.SUBMITTED
@ -174,7 +217,9 @@ class Statement(CommonModel):
total still differs from the transaction total.) total still differs from the transaction total.)
- If the statement is associated with an excursion: allowances, subsidies, LJP paiment and org fee. - 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: if self.excursion is not None:
needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()]) 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.append((self.ljp_to, self.paid_ljp_contributions))
needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk) 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) 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 = [] issues = []
for member, amount in target.items(): for member, amount in target.items():
@ -274,8 +329,9 @@ class Statement(CommonModel):
def is_valid(self): def is_valid(self):
return self.validity == Statement.VALID return self.validity == Statement.VALID
is_valid.boolean = True is_valid.boolean = True
is_valid.short_description = _('Ready to confirm') is_valid.short_description = _("Ready to confirm")
def confirm(self, confirmer=None): def confirm(self, confirmer=None):
if not self.submitted: if not self.submitted:
@ -303,7 +359,13 @@ class Statement(CommonModel):
if not bill.paid_by: if not bill.paid_by:
return False return False
ref = "{}: {}".format(str(self), bill.short_description) 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 # excursion specific
if self.excursion is None: if self.excursion is None:
@ -311,23 +373,46 @@ class Statement(CommonModel):
# allowance # allowance
for yl in self.allowance_to.all(): for yl in self.allowance_to.all():
ref = _("Allowance for %(excu)s") % {'excu': self.excursion.name} ref = _("Allowance for %(excu)s") % {"excu": self.excursion.name}
Transaction(statement=self, member=yl, amount=self.allowance_per_yl, confirmed=False, reference=ref).save() Transaction(
statement=self,
member=yl,
amount=self.allowance_per_yl,
confirmed=False,
reference=ref,
).save()
# subsidies (i.e. night and transportation costs) # subsidies (i.e. night and transportation costs)
if self.subsidy_to: if self.subsidy_to:
ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name} 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() Transaction(
statement=self,
member=self.subsidy_to,
amount=self.total_subsidies,
confirmed=False,
reference=ref,
).save()
if self.total_org_fee: if self.total_org_fee:
# if no subsidy receiver is given but org fees have to be paid. Just pick one of allowance receivers # if no subsidy receiver is given but org fees have to be paid. Just pick one of allowance receivers
ref = _("reduced by org fee") 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: if self.ljp_to:
ref = _("LJP-Contribution %(excu)s") % {'excu': self.excursion.name} ref = _("LJP-Contribution %(excu)s") % {"excu": self.excursion.name}
Transaction(statement=self, member=self.ljp_to, amount=self.paid_ljp_contributions, Transaction(
confirmed=False, reference=ref).save() statement=self,
member=self.ljp_to,
amount=self.paid_ljp_contributions,
confirmed=False,
reference=ref,
).save()
return True return True
@ -335,11 +420,15 @@ class Statement(CommonModel):
# to minimize the number of needed bank transactions, we bundle transactions from same ledger to # to minimize the number of needed bank transactions, we bundle transactions from same ledger to
# same member # same member
transactions = self.transaction_set.all() 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 return
sort_key = lambda trans: (trans.member.pk, trans.ledger.pk) def sort_key(trans):
group_key = lambda trans: (trans.member, trans.ledger) return (trans.member.pk, trans.ledger.pk)
def group_key(trans):
return (trans.member, trans.ledger)
transactions = sorted(transactions, key=sort_key) transactions = sorted(transactions, key=sort_key)
for pair, transaction_group in groupby(transactions, group_key): for pair, transaction_group in groupby(transactions, group_key):
member, ledger = pair member, ledger = pair
@ -347,10 +436,16 @@ class Statement(CommonModel):
if len(grp) == 1: if len(grp) == 1:
continue continue
new_amount = sum((trans.amount for trans in grp)) new_amount = sum(trans.amount for trans in grp)
new_ref = ", ".join((f"{trans.reference} EUR{trans.amount: .2f}" 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, Transaction(
ledger=ledger).save() statement=self,
member=member,
amount=new_amount,
confirmed=False,
reference=new_ref,
ledger=ledger,
).save()
for trans in grp: for trans in grp:
trans.delete() trans.delete()
@ -367,7 +462,7 @@ class Statement(CommonModel):
def bills_without_proof(self): def bills_without_proof(self):
"""Returns the bills that lack a proof file""" """Returns the bills that lack a proof file"""
return [bill for bill in self.bill_set.all() if not bill.proof] return [bill for bill in self.bill_set.all() if not bill.proof]
@property @property
def total_bills_theoretic(self): def total_bills_theoretic(self):
return sum([bill.amount for bill in self.bill_set.all()]) return sum([bill.amount for bill in self.bill_set.all()])
@ -382,8 +477,10 @@ class Statement(CommonModel):
if self.excursion is None: if self.excursion is None:
return 0 return 0
if self.excursion.tour_approach == MUSKELKRAFT_ANREISE \ if (
or self.excursion.tour_approach == OEFFENTLICHE_ANREISE: self.excursion.tour_approach == MUSKELKRAFT_ANREISE
or self.excursion.tour_approach == OEFFENTLICHE_ANREISE
):
return 0.15 return 0.15
else: else:
return 0.1 return 0.1
@ -431,9 +528,7 @@ class Statement(CommonModel):
@property @property
def total_per_yl(self): def total_per_yl(self):
return self.transportation_per_yl \ return self.transportation_per_yl + self.allowance_per_yl + self.nights_per_yl
+ self.allowance_per_yl \
+ self.nights_per_yl
@property @property
def real_per_yl(self): 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.""" """participants older than 26.99 years need to pay a specified organisation fee per person per day."""
if self.excursion is None: if self.excursion is None:
return 0 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 @property
def total_org_fee(self): def total_org_fee(self):
@ -480,7 +579,7 @@ class Statement(CommonModel):
@property @property
def theoretical_total_staff(self): 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 return self.total_per_yl * self.real_staff_count
@ -495,7 +594,6 @@ class Statement(CommonModel):
def total_staff_paid(self): def total_staff_paid(self):
return self.total_staff - self.total_org_fee return self.total_staff - self.total_org_fee
@property @property
def real_staff_count(self): def real_staff_count(self):
if self.excursion is None: if self.excursion is None:
@ -511,22 +609,26 @@ class Statement(CommonModel):
return 0 return 0
else: else:
return self.excursion.approved_staff_count return self.excursion.approved_staff_count
@property @property
def paid_ljp_contributions(self): 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: if self.excursion.theoretic_ljp_participant_count < 5:
return 0 return 0
return cvt_to_decimal( return cvt_to_decimal(
min( min(
# if total costs are more than the max amount of the LJP contribution, we pay the max amount, reduced by taxes # 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 # 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 # we never pay more than the maximum costs of the trip
float(self.total_bills_not_covered) float(self.total_bills_not_covered),
) )
) )
else: else:
@ -534,7 +636,7 @@ class Statement(CommonModel):
@property @property
def total(self): 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 @property
def total_theoretic(self): def total_theoretic(self):
@ -548,60 +650,61 @@ class Statement(CommonModel):
def total_pretty(self): def total_pretty(self):
return "{}".format(self.total) 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): def template_context(self):
context = { context = {
'total_bills': self.total_bills, "total_bills": self.total_bills,
'total_bills_theoretic': self.total_bills_theoretic, "total_bills_theoretic": self.total_bills_theoretic,
'bills_covered': self.bills_covered, "bills_covered": self.bills_covered,
'total': self.total, "total": self.total,
} }
if self.excursion: if self.excursion:
excursion_context = { excursion_context = {
'nights': self.excursion.night_count, "nights": self.excursion.night_count,
'price_per_night': self.real_night_cost, "price_per_night": self.real_night_cost,
'duration': self.excursion.duration, "duration": self.excursion.duration,
'staff_count': self.real_staff_count, "staff_count": self.real_staff_count,
'kilometers_traveled': self.excursion.kilometers_traveled, "kilometers_traveled": self.excursion.kilometers_traveled,
'means_of_transport': self.excursion.get_tour_approach(), "means_of_transport": self.excursion.get_tour_approach(),
'euro_per_km': self.euro_per_km, "euro_per_km": self.euro_per_km,
'allowance_per_day': settings.ALLOWANCE_PER_DAY, "allowance_per_day": settings.ALLOWANCE_PER_DAY,
'allowances_paid': self.allowances_paid, "allowances_paid": self.allowances_paid,
'nights_per_yl': self.nights_per_yl, "nights_per_yl": self.nights_per_yl,
'allowance_per_yl': self.allowance_per_yl, "allowance_per_yl": self.allowance_per_yl,
'total_allowance': self.total_allowance, "total_allowance": self.total_allowance,
'transportation_per_yl': self.transportation_per_yl, "transportation_per_yl": self.transportation_per_yl,
'total_per_yl': self.total_per_yl, "total_per_yl": self.total_per_yl,
'total_staff': self.total_staff, "total_staff": self.total_staff,
'total_allowance': self.total_allowance, "theoretical_total_staff": self.theoretical_total_staff,
'theoretical_total_staff': self.theoretical_total_staff, "real_staff_count": self.real_staff_count,
'real_staff_count': self.real_staff_count, "total_subsidies": self.total_subsidies,
'total_subsidies': self.total_subsidies, "subsidy_to": self.subsidy_to,
'total_allowance': self.total_allowance, "allowance_to": self.allowance_to,
'subsidy_to': self.subsidy_to, "paid_ljp_contributions": self.paid_ljp_contributions,
'allowance_to': self.allowance_to, "ljp_to": self.ljp_to,
'paid_ljp_contributions': self.paid_ljp_contributions, "theoretic_ljp_participant_count": self.excursion.theoretic_ljp_participant_count,
'ljp_to': self.ljp_to, "participant_count": self.excursion.participant_count,
'theoretic_ljp_participant_count': self.excursion.theoretic_ljp_participant_count, "total_seminar_days": self.excursion.total_seminar_days,
'participant_count': self.excursion.participant_count, "ljp_tax": settings.LJP_TAX * 100,
'total_seminar_days': self.excursion.total_seminar_days, "total_org_fee_theoretical": self.total_org_fee_theoretical,
'ljp_tax': settings.LJP_TAX * 100, "total_org_fee": self.total_org_fee,
'total_org_fee_theoretical': self.total_org_fee_theoretical, "old_participant_count": self.excursion.old_participant_count,
'total_org_fee': self.total_org_fee, "total_staff_paid": self.total_staff_paid,
'old_participant_count': self.excursion.old_participant_count, "org_fee": cvt_to_decimal(settings.EXCURSION_ORG_FEE),
'total_staff_paid': self.total_staff_paid,
'org_fee': cvt_to_decimal(settings.EXCURSION_ORG_FEE),
} }
return dict(context, **excursion_context) return dict(context, **excursion_context)
else: else:
return context return context
def grouped_bills(self): def grouped_bills(self):
return self.bill_set.values('short_description')\ return (
.order_by('short_description')\ self.bill_set.values("short_description")
.annotate(amount=Sum('amount')) .order_by("short_description")
.annotate(amount=Sum("amount"))
)
def send_summary(self, cc=None): def send_summary(self, cc=None):
""" """
@ -609,29 +712,34 @@ class Statement(CommonModel):
""" """
excursion = self.excursion excursion = self.excursion
context = dict(statement=self.template_context(), excursion=excursion, settings=settings) 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] attachments = [bill.proof.path for bill in self.bills_covered if bill.proof]
filename = render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex', filename = render_tex_with_attachments(
context, attachments, save_only=True) 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), send_mail(
sender=settings.DEFAULT_SENDING_MAIL, _("Statement summary for %(title)s") % {"title": self.title},
recipients=[settings.SEKTION_FINANCE_MAIL], settings.SEND_STATEMENT_SUMMARY.format(statement=self.title),
cc=cc, sender=settings.DEFAULT_SENDING_MAIL,
attachments=[media_path(filename)]) recipients=[settings.SEKTION_FINANCE_MAIL],
cc=cc,
attachments=[media_path(filename)],
)
class StatementOnExcursionProxy(Statement): class StatementOnExcursionProxy(Statement):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Statement') verbose_name = _("Statement")
verbose_name_plural = _('Statements') verbose_name_plural = _("Statements")
rules_permissions = { rules_permissions = {
# This is used as an inline on excursions, so we check for excursion permissions. # This is used as an inline on excursions, so we check for excursion permissions.
'add_obj': is_leader, "add_obj": is_leader,
'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), "view_obj": is_leader | has_global_perm("members.view_global_freizeit"),
'change_obj': is_leader & statement_not_submitted, "change_obj": is_leader & statement_not_submitted,
'delete_obj': is_leader & statement_not_submitted, "delete_obj": is_leader & statement_not_submitted,
} }
@ -645,13 +753,15 @@ class StatementUnSubmitted(Statement):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Statement in preparation') verbose_name = _("Statement in preparation")
verbose_name_plural = _('Statements in preparation') verbose_name_plural = _("Statements in preparation")
rules_permissions = { rules_permissions = {
'add_obj': rules.is_staff, "add_obj": rules.is_staff,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statementunsubmitted'), "view_obj": is_creator
'change_obj': is_creator | leads_excursion, | leads_excursion
'delete_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): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Submitted statement') verbose_name = _("Submitted statement")
verbose_name_plural = _('Submitted statements') verbose_name_plural = _("Submitted statements")
permissions = [ permissions = [
('process_statementsubmitted', 'Can manage submitted statements.'), ("process_statementsubmitted", "Can manage submitted statements."),
] ]
@ -682,111 +792,135 @@ class StatementConfirmed(Statement):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Paid statement') verbose_name = _("Paid statement")
verbose_name_plural = _('Paid statements') verbose_name_plural = _("Paid statements")
permissions = [ permissions = [
('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'), ("may_manage_confirmed_statements", "Can view and manage confirmed statements."),
] ]
class Bill(CommonModel): class Bill(CommonModel):
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) statement = models.ForeignKey(Statement, verbose_name=_("Statement"), on_delete=models.CASCADE)
short_description = models.CharField(verbose_name=_('Short description'), max_length=30, blank=False) short_description = models.CharField(
explanation = models.TextField(verbose_name=_('Explanation'), blank=True) verbose_name=_("Short description"), max_length=30, blank=False
)
amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0) explanation = models.TextField(verbose_name=_("Explanation"), blank=True)
paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True,
on_delete=models.SET_NULL) amount = models.DecimalField(
costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False) verbose_name=_("Amount"), max_digits=6, decimal_places=2, default=0
refunded = models.BooleanField(verbose_name=_('Refunded'), default=False) )
paid_by = models.ForeignKey(
proof = RestrictedFileField(verbose_name=_('Proof'), Member, verbose_name=_("Paid by"), null=True, on_delete=models.SET_NULL
upload_to='bill_images', )
blank=True, costs_covered = models.BooleanField(verbose_name=_("Covered"), default=False)
max_upload_size=5, refunded = models.BooleanField(verbose_name=_("Refunded"), default=False)
content_types=['application/pdf',
'image/jpeg', proof = RestrictedFileField(
'image/png', verbose_name=_("Proof"),
'image/gif']) upload_to="bill_images",
blank=True,
max_upload_size=5,
content_types=["application/pdf", "image/jpeg", "image/png", "image/gif"],
)
def __str__(self): def __str__(self):
return "{} ({}€)".format(self.short_description, self.amount) return "{} ({}€)".format(self.short_description, self.amount)
def pretty_amount(self): def pretty_amount(self):
return "{}".format(self.amount) 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): class Meta(CommonModel.Meta):
verbose_name = _('Bill') verbose_name = _("Bill")
verbose_name_plural = _('Bills') verbose_name_plural = _("Bills")
class BillOnExcursionProxy(Bill): class BillOnExcursionProxy(Bill):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Bill') verbose_name = _("Bill")
verbose_name_plural = _('Bills') verbose_name_plural = _("Bills")
rules_permissions = { rules_permissions = {
'add_obj': leads_excursion & not_submitted, "add_obj": leads_excursion & not_submitted,
'view_obj': leads_excursion | has_global_perm('finance.view_global_billonexcursionproxy'), "view_obj": leads_excursion
'change_obj': (leads_excursion | has_global_perm('finance.change_global_billonexcursionproxy')) & not_submitted, | has_global_perm("finance.view_global_billonexcursionproxy"),
'delete_obj': (leads_excursion | has_global_perm('finance.delete_global_billonexcursionproxy')) & not_submitted, "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 BillOnStatementProxy(Bill):
class Meta(CommonModel.Meta): class Meta(CommonModel.Meta):
proxy = True proxy = True
verbose_name = _('Bill') verbose_name = _("Bill")
verbose_name_plural = _('Bills') verbose_name_plural = _("Bills")
rules_permissions = { rules_permissions = {
'add_obj': (is_creator | leads_excursion) & not_submitted, "add_obj": (is_creator | leads_excursion) & not_submitted,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_billonstatementproxy'), "view_obj": is_creator
'change_obj': (is_creator | leads_excursion | has_global_perm('finance.change_global_billonstatementproxy')) | leads_excursion
& (not_submitted | has_global_perm('finance.process_statementsubmitted')), | has_global_perm("finance.view_global_billonstatementproxy"),
'delete_obj': (is_creator | leads_excursion | has_global_perm('finance.delete_global_billonstatementproxy')) "change_obj": (
& not_submitted, 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): class Transaction(models.Model):
reference = models.TextField(verbose_name=_('Reference')) reference = models.TextField(verbose_name=_("Reference"))
amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_('Amount')) amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_("Amount"))
member = models.ForeignKey(Member, verbose_name=_('Recipient'), member = models.ForeignKey(Member, verbose_name=_("Recipient"), on_delete=models.CASCADE)
on_delete=models.CASCADE) ledger = models.ForeignKey(
ledger = models.ForeignKey(Ledger, blank=False, null=True, default=None, verbose_name=_('Ledger'), Ledger,
on_delete=models.SET_NULL) blank=False,
null=True,
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), default=None,
on_delete=models.CASCADE) verbose_name=_("Ledger"),
on_delete=models.SET_NULL,
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'), statement = models.ForeignKey(Statement, verbose_name=_("Statement"), on_delete=models.CASCADE)
blank=True,
null=True, confirmed = models.BooleanField(verbose_name=_("Paid"), default=False)
on_delete=models.SET_NULL, confirmed_date = models.DateTimeField(verbose_name=_("Paid on"), default=None, null=True)
related_name='confirmed_transactions') 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): def __str__(self):
return "T#{}".format(self.pk) return "T#{}".format(self.pk)
@staticmethod @staticmethod
def escape_reference(reference): def escape_reference(reference):
umlaut_map = { umlaut_map = {"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"}
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', pattern = re.compile("|".join(umlaut_map.keys()))
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue',
'ß': 'ss'
}
pattern = re.compile('|'.join(umlaut_map.keys()))
int_reference = pattern.sub(lambda x: umlaut_map[x.group()], reference) int_reference = pattern.sub(lambda x: umlaut_map[x.group()], reference)
allowed_chars = r"[^a-z0-9 /?: .,'+-]" 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 return clean_reference
def code(self): def code(self):
if self.amount == 0: if self.amount == 0:
return "" return ""
@ -796,7 +930,7 @@ class Transaction(models.Model):
bic = iban.bic bic = iban.bic
reference = self.escape_reference(self.reference) reference = self.escape_reference(self.reference)
# also escaping receiver as umlaute are also not allowed here # also escaping receiver as umlaute are also not allowed here
receiver = self.escape_reference(f"{self.member.prename} {self.member.lastname}") receiver = self.escape_reference(f"{self.member.prename} {self.member.lastname}")
return f"""BCD return f"""BCD
@ -812,13 +946,14 @@ EUR{self.amount}
{reference}""" {reference}"""
class Meta: class Meta:
verbose_name = _('Transaction') verbose_name = _("Transaction")
verbose_name_plural = _('Transactions') verbose_name_plural = _("Transactions")
class Receipt(models.Model): class Receipt(models.Model):
short_description = models.CharField(verbose_name=_('Short description'), max_length=30) short_description = models.CharField(verbose_name=_("Short description"), max_length=30)
ledger = models.ForeignKey(Ledger, blank=False, null=False, verbose_name=_('Ledger'), ledger = models.ForeignKey(
on_delete=models.CASCADE) Ledger, blank=False, null=False, verbose_name=_("Ledger"), on_delete=models.CASCADE
)
amount = models.DecimalField(max_digits=6, decimal_places=2) amount = models.DecimalField(max_digits=6, decimal_places=2)
comments = models.TextField() comments = models.TextField()

@ -1,7 +1,7 @@
from members.models import Freizeit
from contrib.rules import memberize_user from contrib.rules import memberize_user
from rules import predicate from members.models import Freizeit
from members.rules import _is_leader from members.rules import _is_leader
from rules import predicate
@predicate @predicate
@ -16,7 +16,7 @@ def is_creator(self, statement):
def not_submitted(self, statement): def not_submitted(self, statement):
assert statement is not None assert statement is not None
if isinstance(statement, Freizeit): if isinstance(statement, Freizeit):
if hasattr(statement, 'statement'): if hasattr(statement, "statement"):
return not statement.statement.submitted return not statement.statement.submitted
else: else:
return True return True
@ -29,7 +29,7 @@ def leads_excursion(self, statement):
assert statement is not None assert statement is not None
if isinstance(statement, Freizeit): if isinstance(statement, Freizeit):
return _is_leader(self, statement) return _is_leader(self, statement)
if not hasattr(statement, 'excursion'): if not hasattr(statement, "excursion"):
return False return False
if statement.excursion is None: if statement.excursion is None:
return False return False

Loading…
Cancel
Save