feat(finance): creation of receipts for confirmed statements and payment of LJP contributions (#150)

Confirmed statements now come with automatically generated PDF receipts used for documenting all issued payments.

This PR also adds generation of transactions for LJP contributions and their validation.

closes #92

Co-authored-by: mariusrklein <47218379+mariusrklein@users.noreply.github.com>
Reviewed-on: #150
Reviewed-by: Christian Merten <christian@merten.dev>
Co-authored-by: marius.klein <marius.klein@alpenverein-heidelberg.de>
Co-committed-by: marius.klein <marius.klein@alpenverein-heidelberg.de>
pull/153/head
marius.klein 8 months ago committed by Christian Merten
parent 33ab4e481d
commit f213e11772

@ -1,4 +1,5 @@
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
@ -13,6 +14,7 @@ from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin
from utils import get_member, RestrictedFileField
from rules.contrib.admin import ObjectPermissionsModelAdmin
from members.pdf import render_tex_with_attachments
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\
StatementUnSubmitted, BillOnStatementProxy
@ -115,7 +117,7 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
memberlist=memberlist,
object=memberlist,
participant_count=memberlist.participant_count,
ljp_contributions=memberlist.potential_ljp_contributions,
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)
@ -129,12 +131,23 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
class TransactionOnSubmittedStatementInline(admin.TabularInline):
model = Transaction
fields = ['amount', 'member', 'reference', 'ledger']
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):
"""Display reference length, warn if exceeds 140 characters."""
len_reference = len(obj.reference)
len_string = f"{len_reference}/140"
if len_reference > 140:
return mark_safe(f'<span style="color: red;">{len_string}</span>')
return len_string
text_length_warning.short_description = _("Length")
class BillOnSubmittedStatementInline(BillOnStatementInline):
model = BillOnStatementProxy
@ -217,6 +230,9 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
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:finance_statementconfirmed_summary', 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)))
if "confirm" in request.POST:
res = statement.validity
@ -271,6 +287,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
title=_('View submitted statement'),
opts=self.opts,
statement=statement,
settings=settings,
transaction_issues=statement.transaction_issues,
**statement.template_context())
@ -321,6 +338,11 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
wrap(self.unconfirm_view),
name="%s_%s_unconfirm" % (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),
),
]
return custom_urls + urls
@ -348,6 +370,22 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
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:
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,)))
excursion = statement.excursion
context = dict(statement=statement.template_context(), excursion=excursion, settings=settings)
pdf_filename = f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else f"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)
statement_summary_view.short_description = _('Download summary')
@admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin):
"""The transaction admin site. This is only used to display transactions. All editing

@ -48,6 +48,10 @@ msgstr "Kostenübersicht"
msgid "Submit statement"
msgstr "Rechnung einreichen"
#: finance/admin.py
msgid "Length"
msgstr "Länge"
#: finance/admin.py
#, python-format
msgid "%(name)s is not yet submitted."
@ -69,6 +73,13 @@ msgstr ""
"Erfolgreich %(name)s abgewickelt. Ich hoffe du hast die zugehörigen "
"Überweisungen ausgeführt, ich werde dich nicht nochmal erinnern."
#: finance/admin.py
#, python-format
msgid "You can download a <a href='%(link)s', target='_blank'>receipt</a>."
msgstr ""
"Hier kannst du den Abrechnungsbeleg <a href='%(link)s', "
"target='_blank'>herunterladen</a>."
#: finance/admin.py
msgid "Statement confirmed"
msgstr "Abrechnung abgewickelt"
@ -160,6 +171,10 @@ msgstr ""
msgid "Unconfirm statement"
msgstr "Bestätigung zurücknehmen"
#: finance/admin.py
msgid "Download summary"
msgstr "Beleg herunterladen"
#: finance/apps.py
msgid "Finance"
msgstr "Finanzen"
@ -211,6 +226,18 @@ msgstr ""
"Die Person, die die Übernachtungs- und Fahrtkostenzuschüsse erhalten soll. "
"Dies ist in der Regel die Person, die sie bezahlt hat."
#: finance/models.py
msgid "Pay ljp contributions to"
msgstr "LJP-Zuschüsse auszahlen an"
#: finance/models.py
msgid ""
"The person that should receive the ljp contributions for the participants. "
"Should be only selected if an ljp request was submitted."
msgstr ""
"Die Person, die die LJP-Zuschüsse für die Teilnehmenden erhalten soll. Nur "
"auswählen, wenn ein LJP-Antrag abgegeben wird."
#: finance/models.py
msgid "Price per night"
msgstr "Preis pro Nacht"
@ -270,7 +297,12 @@ msgstr "Aufwandsentschädigung für %(excu)s"
msgid "Night and travel costs for %(excu)s"
msgstr "Übernachtungs- und Fahrtkosten für %(excu)s"
#: finance/models.py finance/templates/admin/overview_submitted_statement.html
#: finance/models.py
#, python-format
msgid "LJP-Contribution %(excu)s"
msgstr "LJP-Zuschuss %(excu)s"
#: finance/models.py
msgid "Total"
msgstr "Gesamtbetrag"
@ -509,6 +541,44 @@ msgstr ""
"Keine Empfänger*innen für Sektionszuschüsse angegeben. Es werden daher keine "
"Sektionszuschüsse ausbezahlt."
#: finance/templates/admin/overview_submitted_statement.html
#, python-format
msgid ""
" The youth leaders have documented interventions worth of "
"%(total_seminar_days)s seminar \n"
"days for %(participant_count)s eligible participants. Taking into account "
"the maximum contribution quota \n"
"of 90%% and possible taxes (%(ljp_tax)s%%), this results in a total of "
"%(paid_ljp_contributions)s€. \n"
"Once their proposal was approved, the ljp contributions of should be paid to:"
msgstr ""
"Jugendleiter*innen haben Lerneinheiten für insgesamt %(total_seminar_days)s "
"Seminartage und für %(participant_count)s Teilnehmende dokumentiert. Unter "
"Einbezug der maximalen Förderquote von 90%% und möglichen Steuern "
"(%(ljp_tax)s%%), ergibt sich ein auszuzahlender Betrag von "
"%(paid_ljp_contributions)s€. Sobald der LJP-Antrag geprüft ist, können LJP-"
"Zuschüsse ausbezahlt werden an:"
#: finance/templates/admin/overview_submitted_statement.html
msgid "Summary"
msgstr "Zusammenfassung"
#: finance/templates/admin/overview_submitted_statement.html
msgid "Covered bills"
msgstr "Übernommene Ausgaben"
#: finance/templates/admin/overview_submitted_statement.html
msgid "Allowance"
msgstr "Aufwandsentschädigung"
#: finance/templates/admin/overview_submitted_statement.html
msgid "Contributions by the association"
msgstr "Sektionszuschüsse"
#: finance/templates/admin/overview_submitted_statement.html
msgid "ljp contributions"
msgstr "LJP-Zuschüsse"
#: finance/templates/admin/overview_submitted_statement.html
#, python-format
msgid "This results in a total amount of %(total)s€"

@ -0,0 +1,20 @@
# Generated by Django 4.2.20 on 2025-04-03 21:04
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('members', '0039_membertraining_certificate_attendance'),
('finance', '0008_alter_statement_allowance_to_and_more'),
]
operations = [
migrations.AddField(
model_name='statement',
name='ljp_to',
field=models.ForeignKey(blank=True, help_text='The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receives_ljp_for_statements', to='members.member', verbose_name='Pay ljp contributions to'),
),
]

@ -70,6 +70,13 @@ class Statement(CommonModel):
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)
submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
@ -128,6 +135,8 @@ class Statement(CommonModel):
needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()])
if self.subsidy_to:
needed_paiments.append((self.subsidy_to, self.total_subsidies))
if self.ljp_to:
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])))
@ -242,6 +251,10 @@ class Statement(CommonModel):
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.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()
return True
def reduce_transactions(self):
@ -261,7 +274,7 @@ class Statement(CommonModel):
continue
new_amount = sum((trans.amount for trans in grp))
new_ref = "\n".join((trans.reference 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:
@ -269,12 +282,27 @@ class Statement(CommonModel):
@property
def total_bills(self):
return sum([bill.amount for bill in self.bill_set.all() if bill.costs_covered])
return sum([bill.amount for bill in self.bills_covered])
@property
def bills_covered(self):
"""Returns the bills that are marked for reimbursement by the finance officer"""
return [bill for bill in self.bill_set.all() if bill.costs_covered]
@property
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()])
@property
def total_bills_not_covered(self):
"""Returns the sum of bills that are not marked for reimbursement by the finance officer"""
return sum([bill.amount for bill in self.bill_set.all()]) - self.total_bills
@property
def euro_per_km(self):
if self.excursion is None:
@ -381,9 +409,22 @@ class Statement(CommonModel):
else:
return self.excursion.approved_staff_count
@property
def paid_ljp_contributions(self):
if hasattr(self.excursion, 'ljpproposal') and self.ljp_to:
return cvt_to_decimal(
min(
(1-settings.LJP_TAX) * settings.LJP_CONTRIBUTION_PER_DAY * self.excursion.ljp_participant_count * self.excursion.ljp_duration,
(1-settings.LJP_TAX) * 0.9 * (float(self.total_bills_not_covered) + float(self.total_staff) ),
float(self.total_bills_not_covered)
)
)
else:
return 0
@property
def total(self):
return self.total_bills + self.total_staff
return self.total_bills + self.total_staff + self.paid_ljp_contributions
@property
def total_theoretic(self):
@ -403,6 +444,7 @@ class Statement(CommonModel):
context = {
'total_bills': self.total_bills,
'total_bills_theoretic': self.total_bills_theoretic,
'bills_covered': self.bills_covered,
'total': self.total,
}
if self.excursion:
@ -421,9 +463,18 @@ class Statement(CommonModel):
'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,
'participant_count': self.excursion.participant_count,
'total_seminar_days': self.excursion.total_seminar_days,
'ljp_tax': settings.LJP_TAX * 100,
}
return dict(context, **excursion_context)
else:

@ -113,9 +113,65 @@
<p>{% blocktrans %}No receivers of the subsidies were provided. Subsidies will not be used.{% endblocktrans %}</p>
{% endif %}
{% if statement.ljp_to %}
<p>
{% blocktrans %} The youth leaders have documented interventions worth of {{ total_seminar_days }} seminar
days for {{ participant_count }} eligible participants. Taking into account the maximum contribution quota
of 90% and possible taxes ({{ ljp_tax }}%), this results in a total of {{ paid_ljp_contributions }}€.
Once their proposal was approved, the ljp contributions of should be paid to:{% endblocktrans %}
<table>
<th>
<td>{% trans "IBAN valid" %}</td>
</th>
<tr>
<td>{{ statement.ljp_to.name }}</td>
<td>{{ statement.ljp_to.iban_valid|render_bool }}</td>
</tr>
</table>
</p>
{% endif %}
<h2>{% trans "Total" %}</h2>
{% endif %}
<h2>{% trans "Summary" %}</h2>
<table>
<tr>
<td>
{% trans "Covered bills" %}
</td>
<td>
{{ total_bills }}€
</td>
</tr>
<tr>
<td>
{% trans "Allowance" %}
</td>
<td>
{{ total_allowance }}€
</td>
</tr>
<tr>
<td>
{% trans "Contributions by the association" %}
</td>
<td>
{{ total_subsidies }}€
</td>
</tr>
<tr>
<td>
{% trans "ljp contributions" %}
</td>
<td>
{{ paid_ljp_contributions }}€
</td>
</tr>
</table>
<p>
{% blocktrans %}This results in a total amount of {{ total }}€{% endblocktrans %}

@ -0,0 +1,137 @@
{% extends "members/tex_base.tex" %}
{% load static common tex_extras %}
{% block title %}Abrechnungs- und Zuschussbeleg\\[2mm]Sektionsveranstaltung{% endblock %}
{% block content %}
{% if excursion %}
\noindent\textbf{\large Ausfahrt}
% DESCRIPTION TABLE
\begin{table}[H]
\begin{tabular}{ll}
Aktivität: & {{ excursion.name|esc_all }} \\
Ordnungsnummer & {{ excursion.code|esc_all }} \\
Ort / Stützpunkt: & {{ excursion.place|esc_all }} \\
Zeitraum: & {{ excursion.duration|esc_all}} Tage ({{ excursion.time_period_str|esc_all }}) \\
Teilnehmer*innen: & {{ excursion.participant_count }} der Gruppe(n) {{ excursion.groups_str|esc_all }} \\
Betreuer*innen: & {{excursion.staff_count|esc_all }} ({{ excursion.staff_str|esc_all }}) \\
Art der Tour: & {% checked_if_true 'Gemeinschaftstour' excursion.get_tour_type %}
{% checked_if_true 'Führungstour' excursion.get_tour_type %}
{% checked_if_true 'Ausbildung' excursion.get_tour_type %} \\
Anreise: & {% checked_if_true 'ÖPNV' excursion.get_tour_approach %}
{% checked_if_true 'Muskelkraft' excursion.get_tour_approach %}
{% checked_if_true 'Fahrgemeinschaften' excursion.get_tour_approach %}
\end{tabular}
\end{table}
\noindent\textbf{\large Zuschüsse und Aufwandsentschädigung}
{% if excursion.approved_staff_count > 0 %}
\noindent Gemäß Beschluss des Jugendausschusses gelten folgende Sätze für Zuschüsse pro genehmigter Jugendleiter*in:
\begin{table}[H]
\centering
\begin{tabularx}{.97\textwidth}{Xllr}
\toprule
\textbf{Posten} & \textbf{Einzelsatz} & \textbf{Anzahl} & \textbf{Gesamtbetrag pro JL} \\
\midrule
Zuschuss Übernachtung & {{ statement.price_per_night }} € / Nacht & {{ statement.nights }} Nächte & {{ statement.nights_per_yl }}\\
Zuschuss Anreise & {{statement.euro_per_km}} € / km ({{ statement.means_of_transport }}) & {{ statement.kilometers_traveled }} km & {{ statement.transportation_per_yl }}\\
Aufwandsentschädigung & {{ statement.allowance_per_day }},00 € / Tag & {{ statement.duration }} Tage & {{ statement.allowance_per_yl }}\\
\midrule
\textbf{Summe}& & & \textbf{ {{ statement.total_per_yl }} }\\
\bottomrule
\end{tabularx}
\end{table}
\noindent Gemäß JDAV-Betreuungsschlüssel können bei {{ excursion.participant_count }} Teilnehmer*innen
bis zu {{ excursion.approved_staff_count }} Jugendleiter*innen {% if excursion.approved_extra_youth_leader_count %}
(davon {{ excursion.approved_extra_youth_leader_count }} durch das Jugendreferat zusätzlich genehmigt){% endif %} bezuschusst werden.
Zuschüsse und Aufwandsentschädigung werden wie folgt abgerufen:
\begin{itemize}
{% if statement.allowances_paid > 0 %}
\item Eine Aufwandsentschädigung von {{ statement.allowance_per_yl }} € pro Jugendleiter*in wird überwiesen an:
{% for m in statement.allowance_to.all %}{% if forloop.counter > 1 %}, {% endif %}{{ m.name }}{% endfor %}
{% else %}
\item Keiner*r der Jugendleiter*innen nimmt eine Aufwandsentschädigung in Anspruch.
{% endif %}
{% if statement.subsidy_to %}
\item Der Zuschuss zu Übernachtung und Anreise für alle Jugendleiter*innen in Höhe von {{ statement.total_subsidies }} € wird überwiesen an:
{{ statement.subsidy_to.name }}
{% else %}
\item Zuschüsse zu Übernachtung und Anreise werden nicht in Anspruch genommen.
{% endif %}
\end{itemize}
{% else %}
\noindent Für die vorliegende Ausfahrt sind keine Jugendleiter*innen anspruchsberechtigt für Zuschüsse oder Aufwandsentschädigung.
{% endif %}
{% if statement.ljp_to %}
\noindent\textbf{LJP-Zuschüsse}
\noindent Der LJP-Zuschuss für die Teilnehmenden in Höhe von {{ statement.paid_ljp_contributions|esc_all }} € wird überwiesen an:
{{ statement.ljp_to.name|esc_all }} Dieser Zuschuss wird aus Landesmitteln gewährt und ist daher
in der Ausgabenübersicht gesondert aufgeführt.
{% endif %}
{% else %}
\vspace{110pt}
{% endif %}
\vspace{12pt}
\noindent\textbf{\large Ausgabenübersicht}
\nopagebreak
\begin{table}[H]
\centering
\begin{tabularx}{.97\textwidth}{lXlr}
\toprule
\textbf{Titel} & \textbf{Beschreibung} & \textbf{Auszahlung an} & \textbf{Betrag} \\
\midrule
{% if statement.bills_covered %}
{% for bill in statement.bills_covered %}
{{ forloop.counter }}. {{ bill.short_description}} & {{ bill.explanation}} & {{ bill.paid_by.name|esc_all }} & {{ bill.amount }}\\
{% endfor %}
\midrule
\multicolumn{3}{l}{\textbf{Summe übernommene Ausgaben}} & \textbf{ {{ statement.total_bills }} }\\
{% endif %}
{% if excursion.approved_staff_count > 0 and statement.allowances_paid > 0 or excursion.approved_staff_count > 0 and statement.subsidy_to %}
\midrule
{% if statement.allowances_paid > 0 %}
{% for m in statement.allowance_to.all %}
Aufwandsentschädigung & & {{ m.name|esc_all }} & {{ statement.allowance_per_yl }}\\
{% endfor %}
{% endif %}
{% if statement.subsidy_to %}
\multicolumn{2}{l}{Zuschuss Übernachtung und Anreise für alle Jugendleiter*innen} & {{ statement.subsidy_to.name|esc_all }} & {{ statement.total_subsidies }}\\
{% endif %}
\midrule
\multicolumn{3}{l}{\textbf{Summe Zuschüsse und Aufwandsentschädigung Jugendleitende}} & \textbf{ {{ statement.total_staff }} }\\
{%endif %}
{% if statement.ljp_to %}
\midrule
LJP-Zuschuss für die Teilnehmenden && {{ statement.ljp_to.name|esc_all }} & {{ statement.paid_ljp_contributions|esc_all }}\\
{% endif %}
{% if statement.ljp_to or statement.bills_covered and excursion.approved_staff_count > 0 %}
\midrule
\textbf{Gesamtsumme}& & & \textbf{ {{ statement.total }} }\\
{% endif %}
\bottomrule
\end{tabularx}
\end{table}
\noindent Dieser Beleg wird automatisch erstellt und daher nicht unterschrieben.
{% endblock %}

@ -20,6 +20,7 @@ DIGITAL_MAIL = get_var('section', 'digital_mail', default='bar@example.org')
V32_HEAD_ORGANISATION = get_var('LJP', 'v32_head_organisation', default='not configured')
LJP_CONTRIBUTION_PER_DAY = get_var('LJP', 'contribution_per_day', default=25)
LJP_TAX = get_var('LJP', 'tax', default=0)
# echo

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-01 14:54+0100\n"
"POT-Creation-Date: 2025-04-06 19:10+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -236,6 +236,10 @@ msgstr "Löschen?"
msgid "Unconfirm"
msgstr "Bestätigung zurücknehmen"
#: templates/admin/finance/statementconfirmed/change_form_object_tools.html
msgid "Download summary"
msgstr "Beleg herunterladen"
#: templates/admin/finance/statementsubmitted/change_form_object_tools.html
msgid "Reduce transactions"
msgstr "Überweisungen minimieren"

@ -898,10 +898,11 @@ class StatementOnListForm(forms.ModelForm):
# of subsidies and allowance
self.fields['allowance_to'].queryset = excursion.jugendleiter.all()
self.fields['subsidy_to'].queryset = excursion.jugendleiter.all()
self.fields['ljp_to'].queryset = excursion.jugendleiter.all()
class Meta:
model = Statement
fields = ['night_cost', 'allowance_to', 'subsidy_to']
fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to']
def clean(self):
"""Check if the `allowance_to` and `subsidy_to` fields are compatible with
@ -922,7 +923,7 @@ class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedIn
extra = 1
description = _('Please list here all expenses in relation with this excursion and upload relevant bills. These have to be permanently stored for the application of LJP contributions. The short descriptions are used in the seminar report cost overview (possible descriptions are e.g. food, material, etc.).')
sortable_options = []
fields = ['night_cost', 'allowance_to', 'subsidy_to']
fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to']
inlines = [BillOnExcursionInline]
form = StatementOnListForm
@ -1229,6 +1230,12 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
messages.error(request,
_("The configured recipients of the allowance don't match the regulations. Please correct this and try again."))
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(memberlist.pk,)))
if memberlist.statement.ljp_to and len(memberlist.statement.bills_without_proof) > 0:
messages.error(request,
_("The excursion is configured to claim LJP contributions. In that case, for all bills, a proof must be uploaded. Please correct this and try again."))
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(memberlist.pk,)))
memberlist.statement.submit(get_member(request))
messages.success(request,
_("Successfully submited statement. The finance department will notify you as soon as possible."))
@ -1238,8 +1245,7 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
opts=self.opts,
memberlist=memberlist,
object=memberlist,
participant_count=memberlist.participant_count,
ljp_contributions=memberlist.potential_ljp_contributions,
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)

@ -117,7 +117,7 @@ def generate_ljp_vbk(excursion):
sheet['D19'] = settings.SEKTION
sheet['G19'] = title
sheet['I19'] = f"von {excursion.date:%d.%m.%y} bis {excursion.end:%d.%m.%y}"
sheet['J19'] = excursion.duration
sheet['J19'] = excursion.ljp_duration
sheet['L19'] = f"{excursion.ljp_participant_count}"
sheet['H19'] = excursion.get_ljp_activity_category()
sheet['M19'] = f"{excursion.postcode}, {excursion.place}"

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-06 15:38+0200\n"
"POT-Creation-Date: 2025-04-06 18:57+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -417,6 +417,15 @@ msgstr ""
"Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen nicht mit "
"den Richtlinien überein. Bitte korrigiere das und versuche es erneut. "
#: members/admin.py
msgid ""
"The excursion is configured to claim LJP contributions. In that case, for "
"all bills, a proof must be uploaded. Please correct this and try again."
msgstr ""
"Für die Ausfahrt werden LJP-Zuschüsse beantragt. Dafür müssen für alle "
"Ausgaben Belege hochgeladen werden. Bitte lade für alle Ausgaben einen Beleg "
"hoch und versuche es erneut. "
#: members/admin.py
msgid ""
"Successfully submited statement. The finance department will notify you as "
@ -1354,6 +1363,28 @@ msgstr ""
msgid "LJP contributions"
msgstr "LJP Zuschüsse"
#: members/templates/admin/freizeit_finance_overview.html
#, python-format
msgid ""
"By submitting the given seminar report, you will receive LJP contributions.\n"
"You have documented interventions worth of %(total_seminar_days)s seminar "
"days for %(participant_count)s participants.\n"
"This results in a total contribution of %(ljp_contributions)s€.\n"
"To receive them, you need to submit the LJP-Proposal within 3 weeks after "
"your excursion and have it approved by the finance office."
msgstr ""
"Wenn du den erstellten LJP-Antrag einreichst, erhältst du LJP-Zuschüsse. Du "
"hast Lehreinheiten für insgesamt %(total_seminar_days)s Seminartage und für "
"%(participant_count)s Teilnehmende dokumentiert.\n"
"Daraus ergibt sich ein auszahlbarer LJP-Zuschuss von "
"%(ljp_contributions)s€. Um den zu erhalten, musst du den LJP-Antrag "
"innerhalb von 3 Wochen nach der Ausfahrt beim Jugendreferat einreichen und "
"formal genehmigt bekommen."
#: members/templates/admin/freizeit_finance_overview.html
msgid "The LJP contributions are configured to be paid to:"
msgstr "Die LJP-Zuschüsse werden ausgezahlt an:"
#: members/templates/admin/freizeit_finance_overview.html
#, python-format
msgid ""
@ -1361,13 +1392,17 @@ msgid ""
"case,\n"
"you may obtain up to 25€ times %(duration)s days for %(participant_count)s "
"participants but only up to\n"
"90%% of the total costs. This results in a total of %(ljp_contributions)s€."
"90%% of the total costs. This results in a total of %(ljp_contributions)s€. "
"If you have created a seminar report, you need to specify who should receive "
"the contributions in order to make use of them."
msgstr ""
"Indem du einen Seminarbericht anfertigst, kannst du Landesjugendplan (LJP) "
"Zuschüsse beantragen. In diesem Fall kannst du bis zu 25€ mal %(duration)s "
"Tage für %(participant_count)s Teilnehmende, aber nicht mehr als 90%% der "
"Gesamtausgaben erhalten. Das resultiert in einem Gesamtzuschuss von "
"%(ljp_contributions)s€."
"%(ljp_contributions)s€. Wenn du schon einen Seminarbericht erstellt hast, "
"musst du im Tab 'Abrechnungen' noch angeben, an wen die LJP-Zuschüsse "
"ausgezahlt werden sollen."
#: members/templates/admin/freizeit_finance_overview.html
msgid "Summary"

@ -8,6 +8,7 @@ import csv
from django.db import models
from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\
Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef
from django.db.models.functions import TruncDate
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.utils.html import format_html
@ -1278,6 +1279,30 @@ class Freizeit(CommonModel):
else:
return 0
@property
def total_seminar_days(self):
"""calculate seminar days based on intervention hours in every day"""
# TODO: add tests for this
if hasattr(self, 'ljpproposal'):
hours_per_day = (
self.ljpproposal.intervention_set
.annotate(day=TruncDate('date_start')) # Extract the date (without time)
.values('day') # Group by day
.annotate(total_duration=Sum('duration')) # Sum durations for each day
.order_by('day') # Sort results by date
)
# Calculate the total number of seminar days
# Each day is counted as 1 if total_duration is >= 5 hours, as 0.5 if total_duration is >= 2.5
# otherwise 0
return sum([min(math.floor(h['total_duration']/cvt_to_decimal(2.5))/2, 1) for h in hours_per_day])
else:
return 0
@property
def ljp_duration(self):
"""calculate the duration in days for the LJP"""
return min(self.duration, self.total_seminar_days)
@property
def staff_count(self):
return self.jugendleiter.count()
@ -1366,12 +1391,19 @@ class Freizeit(CommonModel):
return cvt_to_decimal(min(self.maximal_ljp_contributions,
0.9 * float(self.statement.total_bills_theoretic) + float(self.statement.total_staff)))
@property
def payable_ljp_contributions(self):
"""from the requested ljp contributions, a tax may be deducted for risk reduction"""
if self.statement.ljp_to:
return self.statement.paid_ljp_contributions
return cvt_to_decimal(self.potential_ljp_contributions * cvt_to_decimal(1 - settings.LJP_TAX))
@property
def total_relative_costs(self):
if not self.statement:
return 0
total_costs = self.statement.total_bills_theoretic
total_contributions = self.statement.total_subsidies + self.potential_ljp_contributions
total_contributions = self.statement.total_subsidies + self.payable_ljp_contributions
return total_costs - total_contributions
@property

@ -47,6 +47,23 @@ def render_docx(name, template_path, context, date=None, save_only=False):
return filename_docx
return serve_media(filename_docx, 'application/docx')
def render_tex_with_attachments(name, template_path, context, attachments, save_only=False):
rendered_pdf = render_tex(name, template_path, context, save_only=True)
reader = PdfReader(media_path(rendered_pdf))
writer = PdfWriter()
writer.append(reader)
pdf_add_attachments(writer, attachments)
with open(media_path(rendered_pdf), 'wb') as output_stream:
writer.write(output_stream)
if save_only:
return rendered_pdf
return serve_pdf(rendered_pdf)
def render_tex(name, template_path, context, date=None, save_only=False):
filename = generate_tex(name, template_path, context, date=date)
@ -73,6 +90,27 @@ def render_tex(name, template_path, context, date=None, save_only=False):
return serve_pdf(filename_pdf)
def pdf_add_attachments(pdf_writer, attachments):
for fp in attachments:
try:
if fp.endswith(".pdf"):
# append pdf directly
img_pdf = PdfReader(fp)
else:
# convert ensures that png files with an alpha channel can be appended
img = Image.open(fp).convert("RGB")
img_io = BytesIO()
img.save(img_io, "pdf")
img_io.seek(0)
img_pdf = PdfReader(img_io)
img_pdf_scaled = scale_pdf_to_a4(img_pdf)
pdf_writer.append(img_pdf_scaled)
except Exception as e:
print("Could not add image", fp)
print(e)
def scale_pdf_page_to_a4(page):
A4_WIDTH, A4_HEIGHT = 595, 842
@ -114,23 +152,7 @@ def fill_pdf_form(name, template_path, fields, attachments=[], date=None, save_o
writer.update_page_form_field_values(None, fields, auto_regenerate=False)
for fp in attachments:
try:
if fp.endswith(".pdf"):
# append pdf directly
img_pdf = PdfReader(fp)
else:
# convert ensures that png files with an alpha channel can be appended
img = Image.open(fp).convert("RGB")
img_io = BytesIO()
img.save(img_io, "pdf")
img_io.seek(0)
img_pdf = PdfReader(img_io)
img_pdf_scaled = scale_pdf_to_a4(img_pdf)
writer.append(img_pdf_scaled)
except Exception as e:
print("Could not add image", fp)
print(e)
pdf_add_attachments(writer, attachments)
with open(media_path(filename_pdf), 'wb') as output_stream:
writer.write(output_stream)

@ -130,14 +130,39 @@ cost plan!
</p>
{% endif %}
{% if memberlist.statement.ljp_to %}
<h3>{% trans "LJP contributions" %}</h3>
<p>
{% blocktrans %}By submitting the given seminar report, you will receive LJP contributions.
You have documented interventions worth of {{ total_seminar_days }} seminar days for {{ participant_count }} participants.
This results in a total contribution of {{ ljp_contributions }}€.
To receive them, you need to submit the LJP-Proposal within 3 weeks after your excursion and have it approved by the finance office.{% endblocktrans %}
</p>
<p>
{% blocktrans %}The LJP contributions are configured to be paid to:{% endblocktrans %}
<table>
<th>
<td>{% trans "IBAN valid" %}</td>
</th>
<tr>
<td>{{ memberlist.statement.ljp_to.name }}</td>
<td>{{ memberlist.statement.ljp_to.iban_valid|render_bool }}</td>
</tr>
</table>
</p>
{% else %}
<p>
{% blocktrans %}By submitting a seminar report, you may apply for LJP contributions. In this case,
you may obtain up to 25€ times {{ duration }} days for {{ participant_count }} participants but only up to
90% of the total costs. This results in a total of {{ ljp_contributions }}€.{% endblocktrans %}
90% of the total costs. This results in a total of {{ ljp_contributions }}€. If you have created a seminar report, you need to specify who should receive the contributions in order to make use of them.{% endblocktrans %}
</p>
{% endif %}
<h3>{% trans "Summary" %}</h3>
<p>
@ -163,7 +188,7 @@ you may obtain up to 25€ times {{ duration }} days for {{ participant_count }}
</tr>
<tr>
<td>
{% trans "Potential LJP contributions" %}
{% if memberlist.statement.ljp_to %}{% trans "LJP contributions" %}{% else %}{% trans "Potential LJP contributions" %}{% endif %}
</td>
<td>
-{{ ljp_contributions }}€

@ -36,7 +36,7 @@
\textbf{Sektion:} & {{ settings.SEKTION }} \\
\textbf{Titel der Maßnahme:} & {% if not memberlist.ljpproposal %}{{ memberlist.name|esc_all }}{% else %}{{ memberlist.ljpproposal.title }} {% endif %} \\
\textbf{Interne Ordnungsnummer:} & {{ memberlist.code|esc_all }} \\
\textbf{Anzahl der durchgeführten Lehrgangstage:} & {{ memberlist.duration }} \\
\textbf{Anzahl der durchgeführten Lehrgangstage:} & {{ memberlist.ljp_duration }} \\
\end{tabular}
\end{table}

@ -7,7 +7,10 @@
{% url opts|admin_urlname:'unconfirm' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Unconfirm' %}</a>
</li>
<li>
{% url opts|admin_urlname:'summary' original.pk|admin_urlquote as invite_url %}
<a class="historylink" target="_blank" href="{% add_preserved_filters invite_url %}">{% trans 'Download summary' %}</a>
</li>
{{block.super}}
{% endblock %}

Loading…
Cancel
Save