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
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(
"<path:object_id>/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(
"<path:object_id>/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(
"<path:object_id>/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(
"<path:object_id>/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(
"<path:object_id>/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 <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)))
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 <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:
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'<span style="color: red;">{len_string}</span>')
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")

@ -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")

@ -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'<span class="statement-{code}">{Statement.STATUS_CHOICES[self.status][1]}</span>')
status_badge.short_description = _('Status')
return format_html(
f'<span class="statement-{code}">{Statement.STATUS_CHOICES[self.status][1]}</span>'
)
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()

@ -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

Loading…
Cancel
Save