Merge branch 'main' into MK/meeting_checklist

pull/154/head
mariusrklein 8 months ago
commit 757408cfd9

@ -1,4 +1,5 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from django.utils.safestring import mark_safe
from django import forms from django import forms
from django.forms import Textarea, ClearableFileInput from django.forms import Textarea, ClearableFileInput
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect
@ -13,6 +14,7 @@ from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin
from utils import get_member, RestrictedFileField from utils import get_member, RestrictedFileField
from rules.contrib.admin import ObjectPermissionsModelAdmin from rules.contrib.admin import ObjectPermissionsModelAdmin
from members.pdf import render_tex_with_attachments
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\
StatementUnSubmitted, BillOnStatementProxy StatementUnSubmitted, BillOnStatementProxy
@ -40,6 +42,23 @@ class BillOnStatementInline(CommonAdminInlineMixin, admin.TabularInline):
form = BillOnStatementInlineForm form = BillOnStatementInlineForm
def decorate_statement_view(model, perm=None):
def decorator(fun):
def aux(self, request, object_id):
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)
if not permitted:
messages.error(request, _('Insufficient permissions.'))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
return fun(self, request, statement)
return aux
return decorator
@admin.register(StatementUnSubmitted) @admin.register(StatementUnSubmitted)
class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'submitted'] fields = ['short_description', 'explanation', 'excursion', 'submitted']
@ -77,8 +96,8 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
] ]
return custom_urls + urls return custom_urls + urls
def submit_view(self, request, object_id): @decorate_statement_view(Statement)
statement = Statement.objects.get(pk=object_id) def submit_view(self, request, statement):
if statement.submitted: if statement.submitted:
messages.error(request, messages.error(request,
_("%(name)s is already submitted.") % {'name': str(statement)}) _("%(name)s is already submitted.") % {'name': str(statement)})
@ -89,7 +108,7 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
messages.success(request, messages.success(request,
_("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)}) _("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))) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
if statement.excursion: if statement.excursion:
memberlist = statement.excursion memberlist = statement.excursion
context = dict(self.admin_site.each_context(request), context = dict(self.admin_site.each_context(request),
@ -98,7 +117,7 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
memberlist=memberlist, memberlist=memberlist,
object=memberlist, object=memberlist,
participant_count=memberlist.participant_count, participant_count=memberlist.participant_count,
ljp_contributions=memberlist.potential_ljp_contributions, ljp_contributions=memberlist.payable_ljp_contributions,
total_relative_costs=memberlist.total_relative_costs, total_relative_costs=memberlist.total_relative_costs,
**memberlist.statement.template_context()) **memberlist.statement.template_context())
return render(request, 'admin/freizeit_finance_overview.html', context=context) return render(request, 'admin/freizeit_finance_overview.html', context=context)
@ -112,12 +131,23 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
class TransactionOnSubmittedStatementInline(admin.TabularInline): class TransactionOnSubmittedStatementInline(admin.TabularInline):
model = Transaction model = Transaction
fields = ['amount', 'member', 'reference', 'ledger'] fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger']
formfield_overrides = { formfield_overrides = {
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})}
} }
readonly_fields = ['text_length_warning']
extra = 0 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): class BillOnSubmittedStatementInline(BillOnStatementInline):
model = BillOnStatementProxy model = BillOnStatementProxy
@ -183,8 +213,8 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
] ]
return custom_urls + urls return custom_urls + urls
def overview_view(self, request, object_id): @decorate_statement_view(StatementSubmitted)
statement = StatementSubmitted.objects.get(pk=object_id) def overview_view(self, request, statement):
if not statement.submitted: if not statement.submitted:
messages.error(request, messages.error(request,
_("%(name)s is not yet submitted.") % {'name': str(statement)}) _("%(name)s is not yet submitted.") % {'name': str(statement)})
@ -200,6 +230,9 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
messages.success(request, messages.success(request,
_("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.")
% {'name': str(statement)}) % {'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))) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
if "confirm" in request.POST: if "confirm" in request.POST:
res = statement.validity res = statement.validity
@ -254,13 +287,14 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
title=_('View submitted statement'), title=_('View submitted statement'),
opts=self.opts, opts=self.opts,
statement=statement, statement=statement,
settings=settings,
transaction_issues=statement.transaction_issues, transaction_issues=statement.transaction_issues,
**statement.template_context()) **statement.template_context())
return render(request, 'admin/overview_submitted_statement.html', context=context) return render(request, 'admin/overview_submitted_statement.html', context=context)
def reduce_transactions_view(self, request, object_id): @decorate_statement_view(StatementSubmitted)
statement = StatementSubmitted.objects.get(pk=object_id) def reduce_transactions_view(self, request, statement):
statement.reduce_transactions() statement.reduce_transactions()
messages.success(request, messages.success(request,
_("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) _("Successfully reduced transactions for %(name)s.") % {'name': str(statement)})
@ -304,11 +338,16 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
wrap(self.unconfirm_view), wrap(self.unconfirm_view),
name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name), 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 return custom_urls + urls
def unconfirm_view(self, request, object_id): @decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements')
statement = StatementConfirmed.objects.get(pk=object_id) def unconfirm_view(self, request, statement):
if not statement.confirmed: if not statement.confirmed:
messages.error(request, messages.error(request,
_("%(name)s is not yet confirmed.") % {'name': str(statement)}) _("%(name)s is not yet confirmed.") % {'name': str(statement)})
@ -331,6 +370,22 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
return render(request, 'admin/unconfirm_statement.html', context=context) 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) @admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin): class TransactionAdmin(admin.ModelAdmin):
"""The transaction admin site. This is only used to display transactions. All editing """The transaction admin site. This is only used to display transactions. All editing

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-01 21:11+0100\n" "POT-Creation-Date: 2025-04-27 23:00+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,6 +18,14 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: finance/admin.py
msgid "Statement not found."
msgstr "Abrechnung nicht gefunden."
#: finance/admin.py
msgid "Insufficient permissions."
msgstr "Unzureichende Berechtigungen."
#: finance/admin.py #: finance/admin.py
#, python-format #, python-format
msgid "%(name)s is already submitted." msgid "%(name)s is already submitted."
@ -40,6 +48,10 @@ msgstr "Kostenübersicht"
msgid "Submit statement" msgid "Submit statement"
msgstr "Rechnung einreichen" msgstr "Rechnung einreichen"
#: finance/admin.py
msgid "Length"
msgstr "Länge"
#: finance/admin.py #: finance/admin.py
#, python-format #, python-format
msgid "%(name)s is not yet submitted." msgid "%(name)s is not yet submitted."
@ -61,6 +73,13 @@ msgstr ""
"Erfolgreich %(name)s abgewickelt. Ich hoffe du hast die zugehörigen " "Erfolgreich %(name)s abgewickelt. Ich hoffe du hast die zugehörigen "
"Überweisungen ausgeführt, ich werde dich nicht nochmal erinnern." "Ü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 #: finance/admin.py
msgid "Statement confirmed" msgid "Statement confirmed"
msgstr "Abrechnung abgewickelt" msgstr "Abrechnung abgewickelt"
@ -152,6 +171,10 @@ msgstr ""
msgid "Unconfirm statement" msgid "Unconfirm statement"
msgstr "Bestätigung zurücknehmen" msgstr "Bestätigung zurücknehmen"
#: finance/admin.py
msgid "Download summary"
msgstr "Beleg herunterladen"
#: finance/apps.py #: finance/apps.py
msgid "Finance" msgid "Finance"
msgstr "Finanzen" msgstr "Finanzen"
@ -203,6 +226,18 @@ msgstr ""
"Die Person, die die Übernachtungs- und Fahrtkostenzuschüsse erhalten soll. " "Die Person, die die Übernachtungs- und Fahrtkostenzuschüsse erhalten soll. "
"Dies ist in der Regel die Person, die sie bezahlt hat." "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 #: finance/models.py
msgid "Price per night" msgid "Price per night"
msgstr "Preis pro Nacht" msgstr "Preis pro Nacht"
@ -262,7 +297,16 @@ msgstr "Aufwandsentschädigung für %(excu)s"
msgid "Night and travel costs for %(excu)s" msgid "Night and travel costs for %(excu)s"
msgstr "Übernachtungs- und Fahrtkosten für %(excu)s" msgstr "Übernachtungs- und Fahrtkosten für %(excu)s"
#: finance/models.py finance/templates/admin/overview_submitted_statement.html #: finance/models.py
msgid "reduced by org fee"
msgstr "reduziert um Org-Beitrag"
#: finance/models.py
#, python-format
msgid "LJP-Contribution %(excu)s"
msgstr "LJP-Zuschuss %(excu)s"
#: finance/models.py
msgid "Total" msgid "Total"
msgstr "Gesamtbetrag" msgstr "Gesamtbetrag"
@ -501,6 +545,65 @@ msgstr ""
"Keine Empfänger*innen für Sektionszuschüsse angegeben. Es werden daher keine " "Keine Empfänger*innen für Sektionszuschüsse angegeben. Es werden daher keine "
"Sektionszuschüsse ausbezahlt." "Sektionszuschüsse ausbezahlt."
#: finance/templates/admin/overview_submitted_statement.html
msgid "Org fee"
msgstr "Organisationsbeitrag"
#: finance/templates/admin/overview_submitted_statement.html
#, python-format
msgid ""
"Since overaged people where part of the excursion, an organisational fee of "
"%(org_fee)s€ per person per day has to be paid. This totals to "
"%(total_org_fee_theoretical)s€. This organisational fee will be accounted "
"against allowances and subsidies."
msgstr ""
"Da Personen über 27 an der Ausfahrt teilnehommen haben, wird ein "
"Organisationsbeitrag von %(org_fee)s€ pro Person und Tag fällig. Der Gesamtbetrag "
"von %(total_org_fee_theoretical)s€ wird mit Zuschüssen und "
"Aufwandsentschädigungen verrechnet."
#: finance/templates/admin/overview_submitted_statement.html
msgid "LJP contributions"
msgstr "LJP-Zuschüsse"
#: 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 #: finance/templates/admin/overview_submitted_statement.html
#, python-format #, python-format
msgid "This results in a total amount of %(total)s€" 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', 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.')) 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) 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) submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
@ -129,6 +136,13 @@ class Statement(CommonModel):
if self.subsidy_to: if self.subsidy_to:
needed_paiments.append((self.subsidy_to, self.total_subsidies)) needed_paiments.append((self.subsidy_to, self.total_subsidies))
# only include org fee if either allowance or subsidy is claimed (part of the property)
if self.total_org_fee:
needed_paiments.append((self.org_fee_payant, -self.total_org_fee))
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) 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])))
@ -241,7 +255,17 @@ class Statement(CommonModel):
if self.subsidy_to: if self.subsidy_to:
ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name} ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name}
Transaction(statement=self, member=self.subsidy_to, amount=self.total_subsidies, confirmed=False, reference=ref).save() Transaction(statement=self, member=self.subsidy_to, amount=self.total_subsidies, confirmed=False, reference=ref).save()
if self.total_org_fee:
# if 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()
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 return True
def reduce_transactions(self): def reduce_transactions(self):
@ -261,7 +285,7 @@ class Statement(CommonModel):
continue continue
new_amount = sum((trans.amount for trans in grp)) 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, Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref,
ledger=ledger).save() ledger=ledger).save()
for trans in grp: for trans in grp:
@ -269,12 +293,27 @@ class Statement(CommonModel):
@property @property
def total_bills(self): 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 @property
def total_bills_theoretic(self): def total_bills_theoretic(self):
return sum([bill.amount for bill in self.bill_set.all()]) return sum([bill.amount for bill in self.bill_set.all()])
@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 @property
def euro_per_km(self): def euro_per_km(self):
if self.excursion is None: if self.excursion is None:
@ -340,6 +379,24 @@ class Statement(CommonModel):
return cvt_to_decimal(self.total_staff / self.excursion.staff_count) return cvt_to_decimal(self.total_staff / self.excursion.staff_count)
@property
def total_org_fee_theoretical(self):
"""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)
@property
def total_org_fee(self):
"""only calculate org fee if subsidies or allowances are claimed."""
if self.subsidy_to or self.allowances_paid > 0:
return self.total_org_fee_theoretical
return cvt_to_decimal(0)
@property
def org_fee_payant(self):
return self.subsidy_to if self.subsidy_to else self.allowance_to.all()[0]
@property @property
def total_subsidies(self): def total_subsidies(self):
""" """
@ -351,6 +408,10 @@ class Statement(CommonModel):
else: else:
return cvt_to_decimal(0) return cvt_to_decimal(0)
@property
def subsidies_paid(self):
return self.total_subsidies - self.total_org_fee
@property @property
def theoretical_total_staff(self): def theoretical_total_staff(self):
""" """
@ -365,6 +426,11 @@ class Statement(CommonModel):
""" """
return self.total_allowance + self.total_subsidies return self.total_allowance + self.total_subsidies
@property
def total_staff_paid(self):
return self.total_staff - self.total_org_fee
@property @property
def real_staff_count(self): def real_staff_count(self):
if self.excursion is None: if self.excursion is None:
@ -380,10 +446,27 @@ class Statement(CommonModel):
return 0 return 0
else: else:
return self.excursion.approved_staff_count return self.excursion.approved_staff_count
@property
def paid_ljp_contributions(self):
if hasattr(self.excursion, 'ljpproposal') and self.ljp_to:
if self.excursion.theoretic_ljp_participant_count < 5:
return 0
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 @property
def total(self): def total(self):
return self.total_bills + self.total_staff return self.total_bills + self.total_staff_paid + self.paid_ljp_contributions
@property @property
def total_theoretic(self): def total_theoretic(self):
@ -403,6 +486,7 @@ class Statement(CommonModel):
context = { context = {
'total_bills': self.total_bills, 'total_bills': self.total_bills,
'total_bills_theoretic': self.total_bills_theoretic, 'total_bills_theoretic': self.total_bills_theoretic,
'bills_covered': self.bills_covered,
'total': self.total, 'total': self.total,
} }
if self.excursion: if self.excursion:
@ -418,12 +502,28 @@ class Statement(CommonModel):
'allowances_paid': self.allowances_paid, 'allowances_paid': self.allowances_paid,
'nights_per_yl': self.nights_per_yl, 'nights_per_yl': self.nights_per_yl,
'allowance_per_yl': self.allowance_per_yl, 'allowance_per_yl': self.allowance_per_yl,
'total_allowance': self.total_allowance,
'transportation_per_yl': self.transportation_per_yl, 'transportation_per_yl': self.transportation_per_yl,
'total_per_yl': self.total_per_yl, 'total_per_yl': self.total_per_yl,
'total_staff': self.total_staff, 'total_staff': self.total_staff,
'total_allowance': self.total_allowance,
'theoretical_total_staff': self.theoretical_total_staff, 'theoretical_total_staff': self.theoretical_total_staff,
'real_staff_count': self.real_staff_count, 'real_staff_count': self.real_staff_count,
'total_subsidies': self.total_subsidies, 'total_subsidies': self.total_subsidies,
'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),
} }
return dict(context, **excursion_context) return dict(context, **excursion_context)
else: else:

@ -109,13 +109,86 @@
</tr> </tr>
</table> </table>
</p> </p>
{% else %} {% else %}
<p>{% blocktrans %}No receivers of the subsidies were provided. Subsidies will not be used.{% endblocktrans %}</p> <p>{% blocktrans %}No receivers of the subsidies were provided. Subsidies will not be used.{% endblocktrans %}</p>
{% endif %} {% endif %}
{% if total_org_fee %}
<h3>{% trans "Org fee" %}</h3>
{% blocktrans %}Since overaged people where part of the excursion, an organisational fee of {{ org_fee }}€ per person per day has to be paid. This totals to {{ total_org_fee_theoretical }}€. This organisational fee will be accounted against allowances and subsidies.{% endblocktrans %}
{% endif %}
{% if statement.ljp_to %}
<h3>{% trans "LJP contributions" %}</h3>
<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 %}
{% endif %} {% endif %}
<h2>{% trans "Total" %}</h2>
<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 "Org fee" %}
</td>
<td>
-{{ total_org_fee }}€
</td>
</tr>
<tr>
<td>
{% trans "ljp contributions" %}
</td>
<td>
{{ paid_ljp_contributions }}€
</td>
</tr>
</table>
<p> <p>
{% blocktrans %}This results in a total amount of {{ total }}€{% endblocktrans %} {% blocktrans %}This results in a total amount of {{ total }}€{% endblocktrans %}

@ -0,0 +1,146 @@
{% 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 %}
{% if statement.total_org_fee %}
\noindent\textbf{Organisationsbeitrag}
\noindent An der Ausfahrt haben {{ statement.old_participant_count }} Personen teilgenommen, die 27 Jahre alt oder älter sind. Für sie wird pro Tag ein Organisationsbeitrag von {{ statement.org_fee }} € erhoben und mit den bezahlten Zuschüssen und Aufwandsentschädigungen verrechnet.
{% 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 %}
{% if statement.total_org_fee %}
\multicolumn{2}{l}{abzüglich Organisationsbeitrag für {{ statement.old_participant_count }} Teilnehmende über 27 } & & -{{ statement.total_org_fee }}\\
{% endif %}
\midrule
\multicolumn{3}{l}{\textbf{Summe Zuschüsse und Aufwandsentschädigung Jugendleitende}} & \textbf{ {{ statement.total_staff_paid }} }\\
{%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 %}

@ -11,8 +11,8 @@ JET_SIDE_MENU_ITEMS = [
{'name': 'group', 'permissions': ['members.view_group']}, {'name': 'group', 'permissions': ['members.view_group']},
{'name': 'membernotelist', 'permissions': ['members.view_membernotelist']}, {'name': 'membernotelist', 'permissions': ['members.view_membernotelist']},
{'name': 'klettertreff', 'permissions': ['members.view_klettertreff']}, {'name': 'klettertreff', 'permissions': ['members.view_klettertreff']},
{'name': 'activitycategory', 'permissions': ['members.view_group']}, {'name': 'activitycategory', 'permissions': ['members.view_activitycategory']},
{'name': 'trainingcategory', 'permissions': ['members.view_group']}, {'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']},
]}, ]},
{'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [ {'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [
{'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']}, {'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']},

@ -22,6 +22,46 @@ bestätige falls möglich. Zu der Registrierung kommst du hier:
Viele Grüße Viele Grüße
Dein KOMPASS""") Dein KOMPASS""")
GROUP_INVITATION_LEFT_WAITINGLIST = get_text('group_invitation_left_waitinglist',
default="""Hallo {name},
der*die kürzlich zu einer Schnupperstunde für die Gruppe {group} eingeladene Wartende {waiter}
hat die Warteliste verlassen.
Viele Grüße
Dein KOMPASS""")
GROUP_INVITATION_REJECTED = get_text('group_invitation_rejected',
default="""Hallo {name},
{waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} abgelehnt, ist
aber weiterhin auf der Warteliste.
Viele Grüße
Dein KOMPASS""")
GROUP_INVITATION_CONFIRMED_TEXT = get_text('group_invitation_confirmed',
default="""Hallo {name},
{waiter} hat die Einladung zu einer Schnupperstunde bei der Gruppe {group} angenommen.
Viele Grüße
Dein KOMPASS""")
TRIAL_GROUP_MEETING_CONFIRMED_TEXT = get_text('trial_group_meeting_confirmed',
default="""Hallo {name},
deine Teilnahme an der Schnupperstunde der Gruppe {group} wurde erfolgreich bestätigt.
{timeinfo}
Für alle weiteren Absprachen, kontaktiere bitte die Jugendleiter*innen der Gruppe
unter {contact_email}.
Viele Grüße
Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION })
GROUP_TIME_AVAILABLE_TEXT = get_text('group_time_available', GROUP_TIME_AVAILABLE_TEXT = get_text('group_time_available',
default="""Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt.""") default="""Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt.""")
@ -33,7 +73,11 @@ INVITE_TEXT = get_text('invite', default="""Hallo {{name}},
wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {group_name} {group_link}freigeworden. wir haben gute Neuigkeiten für dich. Es ist ein Platz in der Jugendgruppe {group_name} {group_link}freigeworden.
{group_time} {group_time}
Bitte kontaktiere die Gruppenleitung ({contact_email}) für alle weiteren Absprachen. Wenn du an der Schnupperstunde teilnehmen möchtest, bestätige deine Teilnahme bitte unter folgendem Link:
{{invitation_confirm_link}}
Für alle weiteren Absprachen, kontaktiere bitte die Gruppenleitung ({contact_email}).
Wenn du nach der Schnupperstunde beschließt der Gruppe beizutreten, benötigen wir noch ein paar Wenn du nach der Schnupperstunde beschließt der Gruppe beizutreten, benötigen wir noch ein paar
Informationen und eine schriftliche Anmeldebestätigung von dir. Das kannst du alles über folgenden Link erledigen: Informationen und eine schriftliche Anmeldebestätigung von dir. Das kannst du alles über folgenden Link erledigen:

@ -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') 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_CONTRIBUTION_PER_DAY = get_var('LJP', 'contribution_per_day', default=25)
LJP_TAX = get_var('LJP', 'tax', default=0)
# echo # echo
@ -54,6 +55,8 @@ DOMAIN = get_var('misc', 'domain', default='example.org')
ALLOWANCE_PER_DAY = get_var('finance', 'allowance_per_day', default=22) ALLOWANCE_PER_DAY = get_var('finance', 'allowance_per_day', default=22)
MAX_NIGHT_COST = get_var('finance', 'max_night_cost', default=11) MAX_NIGHT_COST = get_var('finance', 'max_night_cost', default=11)
EXCURSION_ORG_FEE = get_var('finance', 'org_fee', default=10)
# links # links
CLOUD_LINK = get_var('links', 'cloud', default='https://startpage.com') CLOUD_LINK = get_var('links', 'cloud', default='https://startpage.com')

@ -236,6 +236,10 @@ msgstr "Löschen?"
msgid "Unconfirm" msgid "Unconfirm"
msgstr "Bestätigung zurücknehmen" 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 #: templates/admin/finance/statementsubmitted/change_form_object_tools.html
msgid "Reduce transactions" msgid "Reduce transactions"
msgstr "Überweisungen minimieren" msgstr "Überweisungen minimieren"

@ -76,6 +76,10 @@ def get_invitation_reject_link(key):
return prepend_base_url("/members/waitinglist/invitation/reject?key={}".format(key)) return prepend_base_url("/members/waitinglist/invitation/reject?key={}".format(key))
def get_invitation_confirm_link(key):
return prepend_base_url("/members/waitinglist/invitation/confirm?key={}".format(key))
def get_wait_confirmation_link(waiter): def get_wait_confirmation_link(waiter):
key = waiter.generate_wait_confirmation_key() key = waiter.generate_wait_confirmation_key()
return prepend_base_url("/members/waitinglist/confirm?key={}".format(key)) return prepend_base_url("/members/waitinglist/confirm?key={}".format(key))

@ -10,6 +10,7 @@ from jdav_web.celery import app
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.conf import settings from django.conf import settings
from contrib.rules import has_global_perm
from contrib.models import CommonModel from contrib.models import CommonModel
from .rules import is_creator from .rules import is_creator
@ -201,9 +202,9 @@ class Message(CommonModel):
("submit_mails", _("Can submit mails")), ("submit_mails", _("Can submit mails")),
) )
rules_permissions = { rules_permissions = {
"view_obj": is_creator, "view_obj": is_creator | has_global_perm('mailer.view_global_message'),
"change_obj": is_creator, "change_obj": is_creator | has_global_perm('mailer.change_global_message'),
"delete_obj": is_creator, "delete_obj": is_creator | has_global_perm('mailer.delete_global_message'),
} }
@ -237,9 +238,9 @@ class Attachment(CommonModel):
verbose_name = _('attachment') verbose_name = _('attachment')
verbose_name_plural = _('attachments') verbose_name_plural = _('attachments')
rules_permissions = { rules_permissions = {
"add_obj": is_creator, "add_obj": is_creator | has_global_perm('mailer.view_global_message'),
"view_obj": is_creator, "view_obj": is_creator | has_global_perm('mailer.view_global_message'),
"change_obj": is_creator, "change_obj": is_creator | has_global_perm('mailer.change_global_message'),
"delete_obj": is_creator, "delete_obj": is_creator | has_global_perm('mailer.delete_global_message'),
} }

@ -241,7 +241,7 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin):
} }
change_form_template = "members/change_member.html" change_form_template = "members/change_member.html"
ordering = ('lastname',) ordering = ('lastname',)
actions = ['request_echo', 'invite_as_user_action'] actions = ['request_echo', 'invite_as_user_action', 'unconfirm']
list_per_page = 25 list_per_page = 25
form = MemberAdminForm form = MemberAdminForm
@ -410,6 +410,12 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin):
name_text_or_link.short_description = _('Name') name_text_or_link.short_description = _('Name')
name_text_or_link.admin_order_field = 'lastname' name_text_or_link.admin_order_field = 'lastname'
def unconfirm(self, request, queryset):
for member in queryset:
member.unconfirm()
messages.success(request, _("Successfully unconfirmed selected members."))
unconfirm.short_description = _('Unconfirm selected members.')
class DemoteToWaiterForm(forms.Form): class DemoteToWaiterForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput) _selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
@ -644,7 +650,8 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
'confirmed_mail', 'waiting_confirmed', 'sent_reminders') 'confirmed_mail', 'waiting_confirmed', 'sent_reminders')
search_fields = ('prename', 'lastname', 'email') search_fields = ('prename', 'lastname', 'email')
list_filter = ['confirmed_mail', InvitedToGroupFilter, AgeFilter, 'gender'] list_filter = ['confirmed_mail', InvitedToGroupFilter, AgeFilter, 'gender']
actions = ['ask_for_registration_action', 'ask_for_wait_confirmation'] actions = ['ask_for_registration_action', 'ask_for_wait_confirmation',
'request_mail_confirmation', 'request_required_mail_confirmation']
inlines = [InvitationToGroupAdmin] inlines = [InvitationToGroupAdmin]
readonly_fields= ['application_date', 'sent_reminders'] readonly_fields= ['application_date', 'sent_reminders']
@ -672,6 +679,18 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
args=(waiter.pk,))) args=(waiter.pk,)))
return ret return ret
def request_mail_confirmation(self, request, queryset):
for member in queryset:
member.request_mail_confirmation()
messages.success(request, _("Successfully requested mail confirmation from selected waiters."))
request_mail_confirmation.short_description = _('Request mail confirmation from selected waiters.')
def request_required_mail_confirmation(self, request, queryset):
for member in queryset:
member.request_mail_confirmation(rerequest=False)
messages.success(request, _("Successfully re-requested missing mail confirmations from selected waiters."))
request_required_mail_confirmation.short_description = _('Re-request missing mail confirmations from selected waiters.')
def get_urls(self): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
@ -713,7 +732,12 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
def invite_view(self, request, object_id): def invite_view(self, request, object_id):
if type(object_id) == str: if type(object_id) == str:
waiter = MemberWaitingList.objects.get(pk=object_id) try:
waiter = MemberWaitingList.objects.get(pk=object_id)
except MemberWaitingList.DoesNotExist:
messages.error(request,
_("A waiter with this ID does not exist."))
return HttpResponseRedirect(reverse('admin:members_memberwaitinglist_changelist'))
queryset = [waiter] queryset = [waiter]
id_list = [waiter.pk] id_list = [waiter.pk]
else: else:
@ -757,7 +781,8 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
_("An error occurred while trying to invite said members. Please try again.")) _("An error occurred while trying to invite said members. Please try again."))
return HttpResponseRedirect(request.get_full_path()) return HttpResponseRedirect(request.get_full_path())
for w in queryset: for w in queryset:
w.invite_to_group(group, text_template=text_template) w.invite_to_group(group, text_template=text_template,
creator=request.user.member if hasattr(request.user, 'member') else None)
messages.success(request, messages.success(request,
_("Successfully invited %(name)s to %(group)s.") % {'name': w.name, 'group': w.invited_for_group.name}) _("Successfully invited %(name)s to %(group)s.") % {'name': w.name, 'group': w.invited_for_group.name})
@ -920,10 +945,11 @@ class StatementOnListForm(forms.ModelForm):
# of subsidies and allowance # of subsidies and allowance
self.fields['allowance_to'].queryset = excursion.jugendleiter.all() self.fields['allowance_to'].queryset = excursion.jugendleiter.all()
self.fields['subsidy_to'].queryset = excursion.jugendleiter.all() self.fields['subsidy_to'].queryset = excursion.jugendleiter.all()
self.fields['ljp_to'].queryset = excursion.jugendleiter.all()
class Meta: class Meta:
model = Statement model = Statement
fields = ['night_cost', 'allowance_to', 'subsidy_to'] fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to']
def clean(self): def clean(self):
"""Check if the `allowance_to` and `subsidy_to` fields are compatible with """Check if the `allowance_to` and `subsidy_to` fields are compatible with
@ -944,7 +970,7 @@ class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedIn
extra = 1 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.).') 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 = [] sortable_options = []
fields = ['night_cost', 'allowance_to', 'subsidy_to'] fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to']
inlines = [BillOnExcursionInline] inlines = [BillOnExcursionInline]
form = StatementOnListForm form = StatementOnListForm
@ -987,7 +1013,7 @@ class MemberOnListInline(CommonAdminInlineMixin, GenericTabularInline):
} }
sortable_options = [] sortable_options = []
template = "admin/members/freizeit/memberonlistinline.html" template = "admin/members/freizeit/memberonlistinline.html"
def people_count(self, obj): def people_count(self, obj):
if isinstance(obj, Freizeit): if isinstance(obj, Freizeit):
# Number of organizers who are also in the Memberlist # Number of organizers who are also in the Memberlist
@ -995,7 +1021,7 @@ class MemberOnListInline(CommonAdminInlineMixin, GenericTabularInline):
# Total number of people in the Memberlist # Total number of people in the Memberlist
total_people = obj.head_count total_people = obj.head_count
else: # fallback if no activity was found else: # fallback if no activity was found
total_people = 0 total_people = 0
organizer_count = 0 organizer_count = 0
@ -1010,7 +1036,6 @@ class MemberOnListInline(CommonAdminInlineMixin, GenericTabularInline):
formset.organizer_count = self.people_count(obj)['organizer_count'] formset.organizer_count = self.people_count(obj)['organizer_count']
return formset return formset
class MemberNoteListAdmin(admin.ModelAdmin): class MemberNoteListAdmin(admin.ModelAdmin):
@ -1061,7 +1086,8 @@ class MemberNoteListAdmin(admin.ModelAdmin):
if not self.may_view_notelist(request, memberlist): if not self.may_view_notelist(request, memberlist):
return self.not_allowed_view(request, memberlist) return self.not_allowed_view(request, memberlist)
context = dict(memberlist=memberlist, settings=settings) context = dict(memberlist=memberlist, settings=settings)
return render_tex(f"{memberlist.title}_Zusammenfassung", 'members/notelist_summary.tex', context) return render_tex(f"{memberlist.title}_Zusammenfassung", 'members/notelist_summary.tex', context,
date=memberlist.date)
summary.short_description = _('Generate PDF summary') summary.short_description = _('Generate PDF summary')
@ -1154,7 +1180,9 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
if not self.may_view_excursion(request, memberlist): if not self.may_view_excursion(request, memberlist):
return self.not_allowed_view(request, memberlist) return self.not_allowed_view(request, memberlist)
context = dict(memberlist=memberlist, settings=settings) context = dict(memberlist=memberlist, settings=settings)
return render_tex(f"{memberlist.code}_{memberlist.name}_Krisenliste", 'members/crisis_intervention_list.tex', context) return render_tex(f"{memberlist.code}_{memberlist.name}_Krisenliste",
'members/crisis_intervention_list.tex', context,
date=memberlist.date)
crisis_intervention_list.short_description = _('Generate crisis intervention list') crisis_intervention_list.short_description = _('Generate crisis intervention list')
def notes_list(self, request, memberlist): def notes_list(self, request, memberlist):
@ -1162,7 +1190,9 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
return self.not_allowed_view(request, memberlist) return self.not_allowed_view(request, memberlist)
people, skills = memberlist.skill_summary people, skills = memberlist.skill_summary
context = dict(memberlist=memberlist, people=people, skills=skills, settings=settings) context = dict(memberlist=memberlist, people=people, skills=skills, settings=settings)
return render_tex(f"{memberlist.code}_{memberlist.name}_Notizen", 'members/notes_list.tex', context) return render_tex(f"{memberlist.code}_{memberlist.name}_Notizen",
'members/notes_list.tex', context,
date=memberlist.date)
notes_list.short_description = _('Generate overview') notes_list.short_description = _('Generate overview')
@decorate_download @decorate_download
@ -1174,13 +1204,17 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
def download_seminar_report_docx(self, request, memberlist): def download_seminar_report_docx(self, request, memberlist):
title = memberlist.ljpproposal.title title = memberlist.ljpproposal.title
context = dict(memberlist=memberlist, settings=settings) context = dict(memberlist=memberlist, settings=settings)
return render_docx(f"{memberlist.code}_{title}_Seminarbericht", 'members/seminar_report_docx.tex', context) return render_docx(f"{memberlist.code}_{title}_Seminarbericht",
'members/seminar_report_docx.tex', context,
date=memberlist.date)
@decorate_download @decorate_download
def download_seminar_report_costs_and_participants(self, request, memberlist): def download_seminar_report_costs_and_participants(self, request, memberlist):
title = memberlist.ljpproposal.title title = memberlist.ljpproposal.title
context = dict(memberlist=memberlist, settings=settings) context = dict(memberlist=memberlist, settings=settings)
return render_tex(f"{memberlist.code}_{title}_TN_Kosten", 'members/seminar_report.tex', context) return render_tex(f"{memberlist.code}_{title}_TN_Kosten",
'members/seminar_report.tex', context,
date=memberlist.date)
def seminar_report(self, request, memberlist): def seminar_report(self, request, memberlist):
if not self.may_view_excursion(request, memberlist): if not self.may_view_excursion(request, memberlist):
@ -1227,7 +1261,9 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
context = memberlist.sjr_application_fields() context = memberlist.sjr_application_fields()
title = memberlist.ljpproposal.title if hasattr(memberlist, 'ljpproposal') else memberlist.name title = memberlist.ljpproposal.title if hasattr(memberlist, 'ljpproposal') else memberlist.name
return fill_pdf_form(f"{memberlist.code}_{title}_SJR_Antrag", 'members/sjr_template.pdf', context, selected_attachments) return fill_pdf_form(f"{memberlist.code}_{title}_SJR_Antrag", 'members/sjr_template.pdf', context,
selected_attachments,
date=memberlist.date)
return self.render_sjr_options(request, memberlist, GenerateSjrForm(attachments=attachments)) return self.render_sjr_options(request, memberlist, GenerateSjrForm(attachments=attachments))
@ -1241,6 +1277,12 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
messages.error(request, messages.error(request,
_("The configured recipients of the allowance don't match the regulations. Please correct this and try again.")) _("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,))) 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)) memberlist.statement.submit(get_member(request))
messages.success(request, messages.success(request,
_("Successfully submited statement. The finance department will notify you as soon as possible.")) _("Successfully submited statement. The finance department will notify you as soon as possible."))
@ -1250,8 +1292,7 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
opts=self.opts, opts=self.opts,
memberlist=memberlist, memberlist=memberlist,
object=memberlist, object=memberlist,
participant_count=memberlist.participant_count, ljp_contributions=memberlist.payable_ljp_contributions,
ljp_contributions=memberlist.potential_ljp_contributions,
total_relative_costs=memberlist.total_relative_costs, total_relative_costs=memberlist.total_relative_costs,
**memberlist.statement.template_context()) **memberlist.statement.template_context())
return render(request, 'admin/freizeit_finance_overview.html', context=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['D19'] = settings.SEKTION
sheet['G19'] = title sheet['G19'] = title
sheet['I19'] = f"von {excursion.date:%d.%m.%y} bis {excursion.end:%d.%m.%y}" 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['L19'] = f"{excursion.ljp_participant_count}"
sheet['H19'] = excursion.get_ljp_activity_category() sheet['H19'] = excursion.get_ljp_activity_category()
sheet['M19'] = f"{excursion.postcode}, {excursion.place}" sheet['M19'] = f"{excursion.postcode}, {excursion.place}"
@ -127,7 +127,8 @@ def generate_ljp_vbk(excursion):
if hasattr(excursion, 'statement'): if hasattr(excursion, 'statement'):
sheet['Q19'] = f"{excursion.statement.total_theoretic}" sheet['Q19'] = f"{excursion.statement.total_theoretic}"
name = normalize_filename(f"{excursion.code}_{title}_LJP_V-BK_3.{excursion.ljpproposal.category}") name = normalize_filename(f"{excursion.code}_{title}_LJP_V-BK_3.{excursion.ljpproposal.category}",
date=excursion.date)
filename = name + ".xlsx" filename = name + ".xlsx"
workbook.save(media_path(filename)) workbook.save(media_path(filename))
return filename return filename

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 23:05+0200\n" "POT-Creation-Date: 2025-04-27 23:00+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -133,6 +133,16 @@ msgstr "Aktivität"
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
#: members/admin.py
msgid "Successfully unconfirmed selected members."
msgstr ""
"Ausgewählte Teilnehmer*innen zu unbestätigten Registrierungen zurückgesetzt."
#: members/admin.py
msgid "Unconfirm selected members."
msgstr ""
"Ausgewählte Teilnehmer*innen zu unbestätigten Registrierungen zurücksetzen."
#: members/admin.py members/tests.py #: members/admin.py members/tests.py
msgid "Successfully requested mail confirmation from selected registrations." msgid "Successfully requested mail confirmation from selected registrations."
msgstr "Aufforderung zur Bestätigung der Email Adresse versendet." msgstr "Aufforderung zur Bestätigung der Email Adresse versendet."
@ -222,10 +232,35 @@ msgstr "Erfolgreich %(name)s aufgefordert den Wartelistenplatz zu bestätigen."
msgid "Ask selected waiters to confirm their waiting status" msgid "Ask selected waiters to confirm their waiting status"
msgstr "Wartende auffordern den Wartelistenplatz zu bestätigen" msgstr "Wartende auffordern den Wartelistenplatz zu bestätigen"
#: members/admin.py
msgid "Successfully requested mail confirmation from selected waiters."
msgstr "Aufforderung zur Bestätigung der Email Adresse versendet."
#: members/admin.py
msgid "Request mail confirmation from selected waiters."
msgstr "Aufforderung zur Bestätigung der Email Adresse versenden"
#: members/admin.py
msgid ""
"Successfully re-requested missing mail confirmations from selected waiters."
msgstr ""
"Erinnerung zur Bestätigung von noch nicht bestätigten Email Adressen "
"versendet."
#: members/admin.py
msgid "Re-request missing mail confirmations from selected waiters."
msgstr ""
"Erinnerung zur Bestätigung von noch nicht bestätigten Email Adressen "
"versenden."
#: members/admin.py #: members/admin.py
msgid "Offer waiter a place in a group." msgid "Offer waiter a place in a group."
msgstr "Personen auf der Warteliste einen Gruppenplatz anbieten." msgstr "Personen auf der Warteliste einen Gruppenplatz anbieten."
#: members/admin.py
msgid "A waiter with this ID does not exist."
msgstr "Es existiert keine wartende Person mit dieser ID."
#: members/admin.py #: members/admin.py
msgid "" msgid ""
"An error occurred while trying to invite said members. Please try again." "An error occurred while trying to invite said members. Please try again."
@ -418,6 +453,15 @@ msgstr ""
"Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen nicht mit " "Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen nicht mit "
"den Richtlinien überein. Bitte korrigiere das und versuche es erneut. " "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 #: members/admin.py
msgid "" msgid ""
"Successfully submited statement. The finance department will notify you as " "Successfully submited statement. The finance department will notify you as "
@ -768,6 +812,10 @@ msgstr "Einladungsdatum"
msgid "Invitation rejected" msgid "Invitation rejected"
msgstr "Einladung abgelehnt" msgstr "Einladung abgelehnt"
#: members/models.py
msgid "Created by"
msgstr "Erstellt von"
#: members/models.py #: members/models.py
msgid "Invitation to group" msgid "Invitation to group"
msgstr "Gruppeneinladung" msgstr "Gruppeneinladung"
@ -792,6 +840,25 @@ msgstr "Ausstehend"
msgid "Status" msgid "Status"
msgstr "Status" msgstr "Status"
#: members/models.py
#, python-format
msgid "%(waiter)s left the waiting list"
msgstr "%(waiter)s hat die Warteliste verlassen"
#: members/models.py
#, python-format
msgid "Group invitation rejected by %(waiter)s"
msgstr "Einladung zur Schnupperstunde von %(waiter)s abgelehnt"
#: members/models.py
#, python-format
msgid "Group invitation confirmed by %(waiter)s"
msgstr "Teilnahme an Schnupperstunde von %(waiter)s bestätigt"
#: members/models.py
msgid "Trial group meeting confirmed"
msgstr "Teilnahme an Schnupperstunde bestätigt"
#: members/models.py #: members/models.py
msgid "Do you want to tell us something else?" msgid "Do you want to tell us something else?"
msgstr "Möchtest du uns noch etwas mitteilen?" msgstr "Möchtest du uns noch etwas mitteilen?"
@ -1317,6 +1384,24 @@ msgstr ""
"Keine Empfänger*innen für Sektionszuschüsse angegeben. Es werden daher keine " "Keine Empfänger*innen für Sektionszuschüsse angegeben. Es werden daher keine "
"Sektionszuschüsse ausbezahlt." "Sektionszuschüsse ausbezahlt."
#: members/templates/admin/freizeit_finance_overview.html
msgid "Org fee"
msgstr "Organisationsbeitrag"
#: members/templates/admin/freizeit_finance_overview.html
#, python-format
msgid ""
"Warning: %(old_participant_count)s participant(s) of the excursion are 27 or "
"older. For each of them, an organisation fee of %(org_fee)s € per day has to "
"be paid to the account. With a duration of %(duration)s days, a total of "
"%(total_org_fee_theoretical)s € is charged against the other transactions."
msgstr ""
"Achtung: %(old_participant_count)s Teilnehmende der Ausfahrt sind 27 oder "
"älter. Für diese Teilnehmende(n) ist ein Org-Beitrag von %(org_fee)s € pro Tag "
"fällig. Durch die Länge der Ausfahrt von %(duration)s Tagen werden insgesamt "
"%(total_org_fee_theoretical)s € mit den Zuschüssen und "
"Aufwandsentschädigungen verrechnet, sofern diese in Anspruch genommen werden."
#: members/templates/admin/freizeit_finance_overview.html #: members/templates/admin/freizeit_finance_overview.html
msgid "" msgid ""
"Warning: The configured recipients of the allowance don't match the " "Warning: The configured recipients of the allowance don't match the "
@ -1332,20 +1417,68 @@ msgstr ""
msgid "LJP contributions" msgid "LJP contributions"
msgstr "LJP Zuschüsse" 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 "Seminar hours"
msgstr "Seminar-Stunden"
#: members/templates/admin/freizeit_finance_overview.html
msgid "Seminar days"
msgstr "Seminar-Tage"
#: members/templates/admin/freizeit_finance_overview.html
msgid "Sum"
msgstr "Summe"
#: 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 #: members/templates/admin/freizeit_finance_overview.html
#, python-format #, python-format
msgid "" msgid ""
"By submitting a seminar report, you may apply for LJP contributions. In this " "By submitting a seminar report, you may apply for LJP contributions. In this "
"case,\n" "case,\n"
"you may obtain up to 25€ times %(duration)s days for %(participant_count)s " "you may obtain up to 25€ times %(duration)s days for "
"participants but only up to\n" "%(theoretic_ljp_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 "" msgstr ""
"Indem du einen Seminarbericht anfertigst, kannst du Landesjugendplan (LJP) " "Indem du einen Seminarbericht anfertigst, kannst du Landesjugendplan (LJP) "
"Zuschüsse beantragen. In diesem Fall kannst du bis zu 25€ mal %(duration)s " "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 " "Tage für %(theoretic_ljp_participant_count)s Teilnehmende, aber nicht mehr "
"Gesamtausgaben erhalten. Das resultiert in einem Gesamtzuschuss von " "als 90%% der Gesamtausgaben erhalten. Das resultiert in einem Gesamtzuschuss "
"%(ljp_contributions)s€." "von %(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
#, python-format
msgid ""
" Warning: LJP contributions can only be claimed for activities with at least "
"5 participants and one leader. This activity currently has only "
"%(theoretic_ljp_participant_count)s participants."
msgstr ""
"Achtung: Nur für Aktivitäten mit mindestens 5 Teilnehmenden und einer "
"Leitungsperson kann ein LJP-Antrag gestellt werden. Diese Ausfahrt hat "
"aktuell nur %(theoretic_ljp_participant_count)s Teilnehmende."
#: members/templates/admin/freizeit_finance_overview.html #: members/templates/admin/freizeit_finance_overview.html
msgid "Summary" msgid "Summary"
@ -1355,6 +1488,10 @@ msgstr "Zusammenfassung"
msgid "This is the estimated cost and contribution summary:" msgid "This is the estimated cost and contribution summary:"
msgstr "Das ist die geschätzte Kosten- und Zuschussübersicht." msgstr "Das ist die geschätzte Kosten- und Zuschussübersicht."
#: members/templates/admin/freizeit_finance_overview.html
msgid "Organisation fees"
msgstr "Org-Beitrag"
#: members/templates/admin/freizeit_finance_overview.html #: members/templates/admin/freizeit_finance_overview.html
msgid "Potential LJP contributions" msgid "Potential LJP contributions"
msgstr "Mögliche LJP Zuschüsse" msgstr "Mögliche LJP Zuschüsse"
@ -1642,19 +1779,77 @@ msgstr "Fähigkeitsniveau"
msgid "Save and confirm registration" msgid "Save and confirm registration"
msgstr "Speichern und Registrierung bestätigen" msgstr "Speichern und Registrierung bestätigen"
#: members/templates/members/confirm_invalid.html
msgid "Confirm invitation"
msgstr "Teilnahme bestätigen"
#: members/templates/members/confirm_invalid.html
#: members/templates/members/reject_invalid.html members/tests.py
msgid "This invitation is invalid or expired."
msgstr "Diese Einladung ist ungültig oder abgelaufen."
#: members/templates/members/confirm_invitation.html
msgid "Confirm trial group meeting invitation"
msgstr "Teilnahme bestätigen"
#: members/templates/members/confirm_invitation.html
#, python-format
msgid "You were invited to a trial group meeting of the group %(groupname)s."
msgstr ""
"Du wurdest zu einer Schnupperstunde in der Gruppe %(groupname)s eingeladen."
#: members/templates/members/confirm_invitation.html
msgid ""
"Do you want to take part in the trial group meeting? If yes, please confirm "
"your attendance by clicking on the following button."
msgstr ""
"Möchtest du an der Schnupperstunde teilnehmen? Falls ja, bitte bestätige "
"deine Teilnahme durch das Betätigen des folgenden Knopfes."
#: members/templates/members/confirm_invitation.html
msgid "Confirm trial group meeting"
msgstr "Teilnahme bestätigen"
#: members/templates/members/confirm_success.html
msgid "Invitation confirmed"
msgstr "Teilnahme bestätigt"
#: members/templates/members/confirm_success.html
#, python-format
msgid ""
"You successfully confirmed the invitation to the trial group meeting of the "
"group %(groupname)s."
msgstr ""
"Deine Teilnahme an der Schnupperstunde der Gruppe %(groupname)s wurde "
"erfolgreich bestätigt."
#: members/templates/members/confirm_success.html
msgid ""
"We have informed the group leaders about your confirmation. If for some "
"reason you can not make it,\n"
"please contact the group leaders at"
msgstr ""
"Wir haben die Jugendleiter*innen der Jugendgruppe über deine Bestätigung "
"informiert. Falls du doch nicht zur Schnupperstunde kommen kannst, "
"informiere bitte die Jugendleiter*innen unter"
#: members/templates/members/echo.html #: members/templates/members/echo.html
#: members/templates/members/echo_failed.html #: members/templates/members/echo_failed.html
#: members/templates/members/echo_password.html #: members/templates/members/echo_password.html
#: members/templates/members/echo_success.html #: members/templates/members/echo_success.html
#: members/templates/members/echo_wrong_password.html #: members/templates/members/echo_wrong_password.html
#: members/templates/members/upload_registration_form.html
#: members/templates/members/upload_registration_form_success.html
msgid "Echo" msgid "Echo"
msgstr "Rückmeldung" msgstr "Rückmeldung"
#: members/templates/members/echo.html members/tests.py #: members/templates/members/echo.html members/tests.py
msgid "Thanks for echoing back. Here is your current data:" msgid ""
"Here is your current data. Please check if it is up to date and change "
"accordingly."
msgstr "" msgstr ""
"Vielen Dank, dass du dich rückmeldest. Hier siehst du deine aktuellen Daten. " "Hier siehst du deine aktuellen Daten. Bitte überprüfe alles und passe es bei "
"Falls sich etwas geändert hat, trage das bitte hier ein." "Bedarf an."
#: members/templates/members/echo_failed.html members/tests.py #: members/templates/members/echo_failed.html members/tests.py
msgid "Echo failed" msgid "Echo failed"
@ -1929,10 +2124,6 @@ msgstr ""
msgid "Reject invitation" msgid "Reject invitation"
msgstr "Einladung ablehnen" msgstr "Einladung ablehnen"
#: members/templates/members/reject_invalid.html members/tests.py
msgid "This invitation is invalid or expired."
msgstr "Diese Einladung ist ungültig oder abgelaufen."
#: members/templates/members/reject_invitation.html #: members/templates/members/reject_invitation.html
#, python-format #, python-format
msgid "" msgid ""
@ -1973,6 +2164,24 @@ msgstr ""
"abgelehnt. Wenn ein Platz in einer anderen Gruppe frei wird, erhältst du " "abgelehnt. Wenn ein Platz in einer anderen Gruppe frei wird, erhältst du "
"eine neue Einladung.\n" "eine neue Einladung.\n"
#: members/templates/members/upload_registration_form.html
#, python-format
msgid ""
"Thank you for echoing back, your data was updated. For legal\n"
"reasons, we also need a signed participation form. In your case, a recent\n"
"participation form is missing. Please <a href=\"%(download_url)s?"
"key=%(key)s\">download</a>\n"
"the pre-filled form, fill in the remaining fields and read the general "
"conditions. If you agree,\n"
"please sign the document and upload a scan or image here."
msgstr ""
"Danke für das Aktualisieren deiner Daten. Aus rechtlichen Gründen, benötigen "
"wir zusätzlich ein schriftliches Anmeldeformular. Für dich liegt uns kein "
"aktuelles Formular vor. Bitte <a href=\"%(download_url)s?key=%(key)s\">lade "
"das Formular herunter</a>, fülle die verbleibenden Felder aus und lese "
"unsere Teilnahmebedingungen. Falls du zustimmst, unterschreibe bitte das "
"Formular und lade hier einen Scan oder ein Bild hoch."
#: members/templates/members/upload_registration_form.html #: members/templates/members/upload_registration_form.html
#, python-format #, python-format
msgid "" msgid ""
@ -2004,14 +2213,16 @@ msgstr "Hochladen"
msgid "The supplied key for uploading a registration form is invalid." msgid "The supplied key for uploading a registration form is invalid."
msgstr "Der verwendete Link zum Hochladen eines Anmeldeformulars ist ungültig." msgstr "Der verwendete Link zum Hochladen eines Anmeldeformulars ist ungültig."
#: members/templates/members/upload_registration_form_success.html
msgid "Thank you for uploading the registration form."
msgstr "Danke für das Hochladen des Anmeldeformulars."
#: members/templates/members/upload_registration_form_success.html #: members/templates/members/upload_registration_form_success.html
#: members/tests.py #: members/tests.py
msgid "" msgid "Our team will process your registration shortly."
"Thank you for uploading the registration form. Our team will process your "
"registration shortly."
msgstr "" msgstr ""
"Danke für das Hochladen des Anmeldeformulars. Unser Jugendleiterteam wird " "Unser Jugendleiter*innenteam wird deine Registrierung so schnell wie möglich "
"deine Registrierung so schnell wie möglich bearbeiten." "bearbeiten."
#: members/templates/members/waiting_confirmation_invalid.html #: members/templates/members/waiting_confirmation_invalid.html
msgid "Waiting confirmation failed" msgid "Waiting confirmation failed"
@ -2227,14 +2438,6 @@ msgstr "Ungültige Notfallkontakte"
#~ msgid "Good conduct certificate presentation needed" #~ msgid "Good conduct certificate presentation needed"
#~ msgstr "Vorlage Führungszeugnis notwendig" #~ msgstr "Vorlage Führungszeugnis notwendig"
#, python-format
#~ msgid ""
#~ "Do you want to reject the invitation to a trial group meeting of the\n"
#~ "group %(invitation.group.name)s?"
#~ msgstr ""
#~ "Möchtest du die Einladung zur Schnupperstunde bei der Gruppe "
#~ "%(invitation.group.name)s wirklich ablehnen?"
#~ msgid "Yes, reject invitation" #~ msgid "Yes, reject invitation"
#~ msgstr "Ja, Einladung ablehnen." #~ msgstr "Ja, Einladung ablehnen."

@ -0,0 +1,19 @@
# Generated by Django 4.2.20 on 2025-04-06 11:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('members', '0039_membertraining_certificate_attendance'),
]
operations = [
migrations.AddField(
model_name='invitationtogroup',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_group_invitations', to='members.member', verbose_name='Created by'),
),
]

@ -8,6 +8,7 @@ import csv
from django.db import models from django.db import models
from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\ from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\
Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef
from django.db.models.functions import Cast
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
from django.utils.html import format_html from django.utils.html import format_html
@ -18,7 +19,8 @@ from utils import RestrictedFileField, normalize_name
import os import os
from mailer.mailutils import send as send_mail, get_mail_confirmation_link,\ from mailer.mailutils import send as send_mail, get_mail_confirmation_link,\
prepend_base_url, get_registration_link, get_wait_confirmation_link,\ prepend_base_url, get_registration_link, get_wait_confirmation_link,\
get_invitation_reject_link, get_invite_as_user_key, get_leave_waitinglist_link get_invitation_reject_link, get_invite_as_user_key, get_leave_waitinglist_link,\
get_invitation_confirm_link
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings from django.conf import settings
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -114,6 +116,14 @@ class Group(models.Model):
# return if the group has all relevant time slot information filled # return if the group has all relevant time slot information filled
return self.weekday and self.start_time and self.end_time return self.weekday and self.start_time and self.end_time
def get_time_info(self):
if self.has_time_info():
return settings.GROUP_TIME_AVAILABLE_TEXT.format(weekday=WEEKDAYS[self.weekday][1],
start_time=self.start_time.strftime('%H:%M'),
end_time=self.end_time.strftime('%H:%M'))
else:
return ""
def get_invitation_text_template(self): def get_invitation_text_template(self):
"""The text template used to invite waiters to this group. This contains """The text template used to invite waiters to this group. This contains
placeholders for the name of the waiter and personalized links.""" placeholders for the name of the waiter and personalized links."""
@ -122,9 +132,7 @@ class Group(models.Model):
else: else:
group_link = '' group_link = ''
if self.has_time_info(): if self.has_time_info():
group_time = settings.GROUP_TIME_AVAILABLE_TEXT.format(weekday=WEEKDAYS[self.weekday][1], group_time = self.get_time_info()
start_time=self.start_time.strftime('%H:%M'),
end_time=self.end_time.strftime('%H:%M'))
else: else:
group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email) group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email)
return settings.INVITE_TEXT.format(group_time=group_time, return settings.INVITE_TEXT.format(group_time=group_time,
@ -351,6 +359,7 @@ class Member(Person):
help_text=_('If the person registered from the waitinglist, this is their application date.')) help_text=_('If the person registered from the waitinglist, this is their application date.'))
objects = MemberManager() objects = MemberManager()
all_objects = models.Manager()
@property @property
def email_fields(self): def email_fields(self):
@ -417,6 +426,10 @@ class Member(Person):
self.save() self.save()
return True return True
def unconfirm(self):
self.confirmed = False
self.save()
def unsubscribe(self, key): def unsubscribe(self, key):
if self.unsubscribe_key == key and timezone.now() <\ if self.unsubscribe_key == key and timezone.now() <\
self.unsubscribe_expire: self.unsubscribe_expire:
@ -508,6 +521,10 @@ class Member(Person):
# get activity overview # get activity overview
return Freizeit.objects.filter(membersonlist__member=self) return Freizeit.objects.filter(membersonlist__member=self)
def generate_upload_registration_form_key(self):
self.upload_registration_form_key = uuid.uuid4().hex
self.save()
def create_from_registration(self, waiter, group): def create_from_registration(self, waiter, group):
"""Given a member, a corresponding waiting-list object and a group, this completes """Given a member, a corresponding waiting-list object and a group, this completes
the registration and requests email confirmations if necessary. the registration and requests email confirmations if necessary.
@ -885,6 +902,11 @@ class InvitationToGroup(models.Model):
date = models.DateField(default=timezone.now, verbose_name=_('Invitation date')) date = models.DateField(default=timezone.now, verbose_name=_('Invitation date'))
rejected = models.BooleanField(verbose_name=_('Invitation rejected'), default=False) rejected = models.BooleanField(verbose_name=_('Invitation rejected'), default=False)
key = models.CharField(max_length=32, default=gen_key) key = models.CharField(max_length=32, default=gen_key)
created_by = models.ForeignKey(Member, verbose_name=_('Created by'),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='created_group_invitations')
class Meta: class Meta:
verbose_name = _('Invitation to group') verbose_name = _('Invitation to group')
@ -902,6 +924,69 @@ class InvitationToGroup(models.Model):
return _('Undecided') return _('Undecided')
status.short_description = _('Status') status.short_description = _('Status')
def send_left_waitinglist_notification_to(self, recipient):
send_mail(_('%(waiter)s left the waiting list') % {'waiter': self.waiter},
settings.GROUP_INVITATION_LEFT_WAITINGLIST.format(name=recipient.prename,
waiter=self.waiter,
group=self.group),
settings.DEFAULT_SENDING_MAIL,
recipient.email)
def send_reject_notification_to(self, recipient):
send_mail(_('Group invitation rejected by %(waiter)s') % {'waiter': self.waiter},
settings.GROUP_INVITATION_REJECTED.format(name=recipient.prename,
waiter=self.waiter,
group=self.group),
settings.DEFAULT_SENDING_MAIL,
recipient.email)
def send_confirm_notification_to(self, recipient):
send_mail(_('Group invitation confirmed by %(waiter)s') % {'waiter': self.waiter},
settings.GROUP_INVITATION_CONFIRMED_TEXT.format(name=recipient.prename,
waiter=self.waiter,
group=self.group),
settings.DEFAULT_SENDING_MAIL,
recipient.email)
def send_confirm_confirmation(self):
self.waiter.send_mail(_('Trial group meeting confirmed'),
settings.TRIAL_GROUP_MEETING_CONFIRMED_TEXT.format(name=self.waiter.prename,
group=self.group,
contact_email=self.group.contact_email,
timeinfo=self.group.get_time_info()))
def notify_left_waitinglist(self):
"""
Inform youth leaders of the group and the inviter that the waiter left the waitinglist,
prompted by this group invitation.
"""
if self.created_by:
self.send_left_waitinglist_notification_to(self.created_by)
for jl in self.group.leiters.all():
self.send_left_waitinglist_notification_to(jl)
def reject(self):
"""Reject this invitation. Informs the youth leaders of the group of the rejection."""
self.rejected = True
self.save()
# send notifications
if self.created_by:
self.send_reject_notification_to(self.created_by)
for jl in self.group.leiters.all():
self.send_reject_notification_to(jl)
def confirm(self):
"""Confirm this invitation. Informs the youth leaders of the group of the invitation."""
self.rejected = False
self.save()
# confirm the confirmation
self.send_confirm_confirmation()
# send notifications
if self.created_by:
self.send_confirm_notification_to(self.created_by)
for jl in self.group.leiters.all():
self.send_confirm_notification_to(jl)
class MemberWaitingList(Person): class MemberWaitingList(Person):
"""A participant on the waiting list""" """A participant on the waiting list"""
@ -1019,7 +1104,7 @@ class MemberWaitingList(Person):
except InvitationToGroup.DoesNotExist: except InvitationToGroup.DoesNotExist:
return False return False
def invite_to_group(self, group, text_template=None): def invite_to_group(self, group, text_template=None, creator=None):
""" """
Invite waiter to given group. Stores a new group invitation Invite waiter to given group. Stores a new group invitation
and sends a personalized e-mail based on the passed template. and sends a personalized e-mail based on the passed template.
@ -1028,12 +1113,13 @@ class MemberWaitingList(Person):
self.save() self.save()
if not text_template: if not text_template:
text_template = group.get_invitation_text_template() text_template = group.get_invitation_text_template()
invitation = InvitationToGroup(group=group, waiter=self) invitation = InvitationToGroup(group=group, waiter=self, created_by=creator)
invitation.save() invitation.save()
self.send_mail(_("Invitation to trial group meeting"), self.send_mail(_("Invitation to trial group meeting"),
text_template.format(name=self.prename, text_template.format(name=self.prename,
link=get_registration_link(invitation.key), link=get_registration_link(invitation.key),
invitation_reject_link=get_invitation_reject_link(invitation.key)), invitation_reject_link=get_invitation_reject_link(invitation.key),
invitation_confirm_link=get_invitation_confirm_link(invitation.key)),
cc=group.contact_email.email) cc=group.contact_email.email)
def unregister(self): def unregister(self):
@ -1213,25 +1299,74 @@ class Freizeit(CommonModel):
else: else:
return 0 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.seminar_time_per_day
# 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
sum_days = sum([h['sum_days'] for h in hours_per_day])
return sum_days
else:
return 0
@property
def seminar_time_per_day(self):
if hasattr(self, 'ljpproposal'):
return (
self.ljpproposal.intervention_set
.annotate(day=Cast('date_start', output_field=models.DateField())) # Force it to date
.values('day') # Group by day
.annotate(total_duration=Sum('duration'))# Sum durations for each day
.annotate(
sum_days=Case(
When(total_duration__gte=5.0, then=Value(1.0)),
When(total_duration__gte=2.5, then=Value(0.5)),
default=Value(0.0),)
)
.order_by('day') # Sort results by date
)
else:
return []
@property
def ljp_duration(self):
"""calculate the duration in days for the LJP"""
return min(self.duration, self.total_seminar_days)
@property @property
def staff_count(self): def staff_count(self):
return self.jugendleiter.count() return self.jugendleiter.count()
@property @property
def staff_on_memberlist(self): def staff_on_memberlist(self):
ps = set(map(lambda x: x.member, self.membersonlist.distinct())) ps = set(map(lambda x: x.member, self.membersonlist.distinct()))
jls = set(self.jugendleiter.distinct()) jls = set(self.jugendleiter.distinct())
return ps.intersection(jls) return ps.intersection(jls)
@property @property
def staff_on_memberlist_count(self): def staff_on_memberlist_count(self):
return len(self.staff_on_memberlist) return len(self.staff_on_memberlist)
@property @property
def participant_count(self): def participant_count(self):
return len(self.participants)
@property
def participants(self):
ps = set(map(lambda x: x.member, self.membersonlist.distinct())) ps = set(map(lambda x: x.member, self.membersonlist.distinct()))
jls = set(self.jugendleiter.distinct()) jls = set(self.jugendleiter.distinct())
return len(ps - jls) return list(ps - jls)
@property
def old_participant_count(self):
old_ps = [m for m in self.participants if m.age() >= 27]
return len(old_ps)
@property @property
def head_count(self): def head_count(self):
@ -1301,12 +1436,19 @@ class Freizeit(CommonModel):
return cvt_to_decimal(min(self.maximal_ljp_contributions, return cvt_to_decimal(min(self.maximal_ljp_contributions,
0.9 * float(self.statement.total_bills_theoretic) + float(self.statement.total_staff))) 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 @property
def total_relative_costs(self): def total_relative_costs(self):
if not self.statement: if not self.statement:
return 0 return 0
total_costs = self.statement.total_bills_theoretic 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 return total_costs - total_contributions
@property @property

@ -20,8 +20,8 @@ def serve_pdf(filename_pdf):
return serve_media(filename_pdf, 'application/pdf') return serve_media(filename_pdf, 'application/pdf')
def generate_tex(name, template_path, context): def generate_tex(name, template_path, context, date=None):
filename = normalize_filename(name) filename = normalize_filename(name, date=date)
filename_tex = filename + '.tex' filename_tex = filename + '.tex'
tmpl = get_template(template_path) tmpl = get_template(template_path)
@ -34,8 +34,8 @@ def generate_tex(name, template_path, context):
return filename return filename
def render_docx(name, template_path, context, save_only=False): def render_docx(name, template_path, context, date=None, save_only=False):
filename = generate_tex(name, template_path, context) filename = generate_tex(name, template_path, context, date=date)
filename_tex = filename + '.tex' filename_tex = filename + '.tex'
filename_docx = filename + '.docx' filename_docx = filename + '.docx'
oldwd = os.getcwd() oldwd = os.getcwd()
@ -47,9 +47,26 @@ def render_docx(name, template_path, context, save_only=False):
return filename_docx return filename_docx
return serve_media(filename_docx, 'application/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)
def render_tex(name, template_path, context, save_only=False): if save_only:
filename = generate_tex(name, template_path, context) 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)
filename_tex = filename + '.tex' filename_tex = filename + '.tex'
filename_pdf = filename + '.pdf' filename_pdf = filename + '.pdf'
# compile using pdflatex # compile using pdflatex
@ -73,32 +90,55 @@ def render_tex(name, template_path, context, save_only=False):
return serve_pdf(filename_pdf) 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): def scale_pdf_page_to_a4(page):
A4_WIDTH, A4_HEIGHT = 595, 842 A4_WIDTH, A4_HEIGHT = 595, 842
page_width = page.mediabox.width page_width = page.mediabox.width
page_height = page.mediabox.height page_height = page.mediabox.height
scale_x = A4_WIDTH / page_width scale_x = A4_WIDTH / page_width
scale_y = A4_HEIGHT / page_height scale_y = A4_HEIGHT / page_height
scale_factor = min(scale_x, scale_y) scale_factor = min(scale_x, scale_y)
new_page = PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT) new_page = PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
page.scale_by(scale_factor) page.scale_by(scale_factor)
x_offset = (A4_WIDTH - page.mediabox.width) / 2 x_offset = (A4_WIDTH - page.mediabox.width) / 2
y_offset = (A4_HEIGHT - page.mediabox.height) / 2 y_offset = (A4_HEIGHT - page.mediabox.height) / 2
new_page.merge_translated_page(page, x_offset, y_offset) new_page.merge_translated_page(page, x_offset, y_offset)
return new_page return new_page
def scale_pdf_to_a4(pdf): def scale_pdf_to_a4(pdf):
scaled_pdf = PdfWriter() scaled_pdf = PdfWriter()
for page in pdf.pages: for page in pdf.pages:
scaled_pdf.add_page(scale_pdf_page_to_a4(page)) scaled_pdf.add_page(scale_pdf_page_to_a4(page))
return scaled_pdf return scaled_pdf
def fill_pdf_form(name, template_path, fields, attachments=[], save_only=False):
filename = normalize_filename(name) def fill_pdf_form(name, template_path, fields, attachments=[], date=None, save_only=False):
filename = normalize_filename(name, date=date)
filename_pdf = filename + '.pdf' filename_pdf = filename + '.pdf'
path = find_template(template_path) path = find_template(template_path)
@ -112,23 +152,7 @@ def fill_pdf_form(name, template_path, fields, attachments=[], save_only=False):
writer.update_page_form_field_values(None, fields, auto_regenerate=False) writer.update_page_form_field_values(None, fields, auto_regenerate=False)
for fp in attachments: pdf_add_attachments(writer, 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)
with open(media_path(filename_pdf), 'wb') as output_stream: with open(media_path(filename_pdf), 'wb') as output_stream:
writer.write(output_stream) writer.write(output_stream)
@ -138,13 +162,13 @@ def fill_pdf_form(name, template_path, fields, attachments=[], save_only=False):
return serve_pdf(filename_pdf) return serve_pdf(filename_pdf)
def merge_pdfs(name, filenames, save_only=False): def merge_pdfs(name, filenames, date=None, save_only=False):
merger = PdfWriter() merger = PdfWriter()
for pdf in filenames: for pdf in filenames:
merger.append(media_path(pdf)) merger.append(media_path(pdf))
filename = normalize_filename(name) filename = normalize_filename(name, date=date)
filename_pdf = filename + ".pdf" filename_pdf = filename + ".pdf"
merger.write(media_path(filename_pdf)) merger.write(media_path(filename_pdf))
merger.close() merger.close()

@ -60,7 +60,7 @@ def _is_leader(member, excursion):
return False return False
if member in excursion.jugendleiter.all(): if member in excursion.jugendleiter.all():
return True return True
yl = [ yl for group in member.group.all() for yl in group.leiters.all() ] yl = [ yl for group in excursion.groups.all() for yl in group.leiters.all() ]
return member in yl return member in yl

@ -122,8 +122,15 @@ cost plan!
</p> </p>
{% else %} {% else %}
<p>{% blocktrans %}No receivers of the subsidies were provided. Subsidies will not be used.{% endblocktrans %}</p> <p>{% blocktrans %}No receivers of the subsidies were provided. Subsidies will not be used.{% endblocktrans %}</p>
{% endif %}
{% if total_org_fee %}
<h3>{% trans "Org fee" %}</h3>
<p>
{% blocktrans %}Warning: {{ old_participant_count }} participant(s) of the excursion are 27 or older. For each of them, an organisation fee of {{ org_fee }} € per day has to be paid to the account. With a duration of {{ duration }} days, a total of {{ total_org_fee_theoretical }} € is charged against the other transactions.{% endblocktrans %}
</p>
{% endif %} {% endif %}
{% if not memberlist.statement.allowance_to_valid %} {% if not memberlist.statement.allowance_to_valid %}
<p> <p>
{% blocktrans %}Warning: The configured recipients of the allowance don't match the regulations. This might be because the number of recipients is bigger then the number of admissable youth leaders for this excursion.{% endblocktrans %} {% blocktrans %}Warning: The configured recipients of the allowance don't match the regulations. This might be because the number of recipients is bigger then the number of admissable youth leaders for this excursion.{% endblocktrans %}
@ -131,12 +138,63 @@ cost plan!
{% endif %} {% endif %}
<h3>{% trans "LJP contributions" %}</h3> <h3>{% trans "LJP contributions" %}</h3>
{% if memberlist.statement.ljp_to %}
<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>
<table>
<tr>
<td></td>
<td>{% trans "Seminar hours" %}</td>
<td>{% trans "Seminar days" %}</td>
</tr>
{% for day in memberlist.seminar_time_per_day %}
<tr>
<td>{{ day.day }}</td>
<td>{{ day.total_duration }}</td>
<td>{{ day.sum_days }}</td>
</tr>
{% endfor %}
<tr>
<td><b>{% trans "Sum" %}</b></td>
<td></td>
<td>{{ total_seminar_days }}</td>
</tr>
</table>
<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> <p>
{% blocktrans %}By submitting a seminar report, you may apply for LJP contributions. In this case, {% 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 you may obtain up to 25€ times {{ duration }} days for {{ theoretic_ljp_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 %}
{% if memberlist.theoretic_ljp_participant_count < 5 %}
<p>
{% blocktrans %} Warning: LJP contributions can only be claimed for activities with at least 5 participants and one leader. This activity currently has only {{ theoretic_ljp_participant_count }} participants.{% endblocktrans %}
</p> </p>
{% endif %}
<h3>{% trans "Summary" %}</h3> <h3>{% trans "Summary" %}</h3>
@ -153,6 +211,14 @@ you may obtain up to 25€ times {{ duration }} days for {{ participant_count }}
{{ total_bills_theoretic }}€ {{ total_bills_theoretic }}€
</td> </td>
</tr> </tr>
<tr>
<td>
{% trans "Organisation fees" %}
</td>
<td>
{{ total_org_fee }}€
</td>
</tr>
<tr> <tr>
<td> <td>
{% trans "Contributions by the association" %} {% trans "Contributions by the association" %}
@ -163,7 +229,7 @@ you may obtain up to 25€ times {{ duration }} days for {{ participant_count }}
</tr> </tr>
<tr> <tr>
<td> <td>
{% trans "Potential LJP contributions" %} {% if memberlist.statement.ljp_to %}{% trans "LJP contributions" %}{% else %}{% trans "Potential LJP contributions" %}{% endif %}
</td> </td>
<td> <td>
-{{ ljp_contributions }}€ -{{ ljp_contributions }}€

@ -0,0 +1,15 @@
{% extends "members/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}
{% trans "Confirm invitation" %}
{% endblock %}
{% block content %}
<h1>{% trans "Confirm invitation" %}</h1>
<p>{% trans "This invitation is invalid or expired." %}</p>
{% endblock %}

@ -0,0 +1,29 @@
{% extends "members/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}
{% trans "Confirm trial group meeting invitation" %}
{% endblock %}
{% block content %}
<h1>{% trans "Confirm trial group meeting invitation" %}</h1>
<p>
{% blocktrans %}You were invited to a trial group meeting of the group {{ groupname }}.{% endblocktrans %}
{{ timeinfo }}
</p>
<p>
{% blocktrans %}Do you want to take part in the trial group meeting? If yes, please confirm your attendance by clicking on the following button.{% endblocktrans %}
</p>
<form action="" method="post">
{% csrf_token %}
<input type="hidden" name="key" value="{{invitation.key}}">
<input type="submit" name="confirm_invitation"
value="{% trans "Confirm trial group meeting" %}"/>
</form>
{% endblock %}

@ -0,0 +1,23 @@
{% extends "members/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}
{% trans "Invitation confirmed" %}
{% endblock %}
{% block content %}
<h1>{% trans "Invitation confirmed" %}</h1>
<p>
{% blocktrans %}You successfully confirmed the invitation to the trial group meeting of the group {{ groupname }}.{% endblocktrans %}
{{ timeinfo }}
</p>
<p>
{% blocktrans %}We have informed the group leaders about your confirmation. If for some reason you can not make it,
please contact the group leaders at{% endblocktrans %}
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>.
</p>
{% endblock %}

@ -1,74 +1,9 @@
{% extends "members/tex_base.tex" %}
{% load static common tex_extras %} {% load static common tex_extras %}
\documentclass[a4paper]{article} {% block title %}Teilnehmer*innenliste Sektionsveranstaltung{% endblock %}
\usepackage[utf8]{inputenc} {% block content %}
% remove all undefined unicode characters instead of throwing an error
\makeatletter
\def\UTFviii@undefined@err#1{}
\makeatother
\usepackage{booktabs}
\usepackage{amssymb}
\usepackage{cmbright}
\usepackage{graphicx}
\usepackage{textpos}
\usepackage[colorlinks, breaklinks]{hyperref}
\usepackage{float}
\usepackage[margin=2cm]{geometry}
\usepackage{array}
\usepackage{tabularx}
\usepackage{ltablex}
\newcommand{\picpos}[4]{
\begin{textblock*}{#1}(#2, #3)
\includegraphics[width=\textwidth]{#4}
\end{textblock*}
}
% custom url command for properly formatting emails
\DeclareUrlCommand\Email{\urlstyle{same}}
% allow linebreak after every character
\expandafter\def\expandafter\UrlBreaks\expandafter{\UrlBreaks
\do\/\do\a\do\b\do\c\do\d\do\e\do\f\do\g\do\h\do\i\do\j\do\k
\do\l\do\m\do\n\do\o\do\p\do\q\do\r\do\s\do\t\do\u\do\v
\do\w\do\x\do\y\do\z
\do\A\do\B\do\C\do\D\do\E\do\F\do\G\do\H\do\I\do\J\do\K
\do\L\do\M\do\N\do\O\do\P\do\Q\do\R\do\S\do\T\do\U\do\V
\do\W\do\X\do\Y\do\Z}
\renewcommand{\arraystretch}{1.5}
\newcolumntype{L}{>{\hspace{0pt}\raggedright\arraybackslash}X}
\newcolumntype{S}{>{\raggedright\arraybackslash\hsize=0.7\hsize}X}
\newcommand{\tickedbox}{
\makebox[0pt][l]{$\square$}\raisebox{.15ex}{\hspace{0.1em}$\checkmark$}
}
\newcommand{\checkbox}{
\makebox[0pt][l]{$\square$}
}
\begin{document}
% HEADER RIGHT
{% settings_value 'DEFAULT_STATIC_PATH' as static_root %}
\picpos{4.5cm}{11.5cm}{0cm}{%
{{ static_root }}/general/img/dav_logo_sektion.png%
}
\begin{textblock*}{5cm}(12cm, 2.3cm)
\begin{flushright}
\small
\noindent Deutscher Alpenverein e. V. \\
Sektion {{ settings.SEKTION }} \\
{{ settings.SEKTION_STREET }} \\
{{ settings.SEKTION_TOWN }} \\
Tel.: {{ settings.SEKTION_TELEPHONE }} \\
Fax: {{ settings.SEKTION_TELEFAX }} \\
{{ settings.SEKTION_CONTACT_MAIL }} \\
\end{flushright}
\end{textblock*}
% HEADLINE
{\noindent\LARGE{Teilnehmer*innenliste\\[2mm]Sektionsveranstaltung}}\\[1mm]
\textit{Erstellt: {{ creation_date }} }\\
% DESCRIPTION TABLE % DESCRIPTION TABLE
\begin{table}[H] \begin{table}[H]
@ -89,7 +24,7 @@
\end{tabular} \end{tabular}
\end{table} \end{table}
\begin{tabularx}{1\linewidth}{lSLSL} \begin{tabularx}{.97\linewidth}{lXXXX}
\toprule \toprule
\# & \textbf{Name} & \textbf{Anschrift} & \textbf{Telefon} & \textbf{Notfallkontakte} \\ \# & \textbf{Name} & \textbf{Anschrift} & \textbf{Telefon} & \textbf{Notfallkontakte} \\
\midrule \midrule
@ -98,7 +33,7 @@
\endfoot \endfoot
{% for m in memberlist.membersonlist.all %} {% for m in memberlist.membersonlist.all %}
{{ forloop.counter }} & {{ forloop.counter }} &
{{ m.member.name|esc_all }} & {{ m.member.name|esc_all }} &
{{ m.member.address_multiline|esc_all }} & {{ m.member.address_multiline|esc_all }} &
{{ m.member.contact_phone_number|esc_all }} & {{ m.member.contact_phone_number|esc_all }} &
@ -115,4 +50,4 @@
\noindent Bitte die ausgefüllte Teilnehmerliste vor Antritt der Aktivität per E-Mail an \noindent Bitte die ausgefüllte Teilnehmerliste vor Antritt der Aktivität per E-Mail an
\href{mailto:{{ settings.SEKTION_CRISIS_INTERVENTION_MAIL }}}{ {{ settings.SEKTION_CRISIS_INTERVENTION_MAIL }} } senden. \href{mailto:{{ settings.SEKTION_CRISIS_INTERVENTION_MAIL }}}{ {{ settings.SEKTION_CRISIS_INTERVENTION_MAIL }} } senden.
\end{document} {% endblock content %}

@ -12,7 +12,7 @@
<h1>{% trans "Echo" %}</h1> <h1>{% trans "Echo" %}</h1>
<p>{% trans "Thanks for echoing back. Here is your current data:" %}</p> <p>{% trans "Here is your current data. Please check if it is up to date and change accordingly." %}</p>
{% if error_message %} {% if error_message %}
<p><b>{{ error_message }}</b></p> <p><b>{{ error_message }}</b></p>

@ -34,7 +34,7 @@
<input type="hidden" name="waiter_key" value="{{ waiter_key }}"> <input type="hidden" name="waiter_key" value="{{ waiter_key }}">
<input type="hidden" name="save"> <input type="hidden" name="save">
<input type="hidden" name="key" value="{{ key }}"> <input type="hidden" name="key" value="{{ key }}">
<p><input type="submit" value="{% trans "Save" %}"/></p> <p><input style="font-size: 14pt" type="submit" value="{% trans "Save" %}"/></p>
</form> </form>
<div id="empty_form" class="form-row" style="display:none"> <div id="empty_form" class="form-row" style="display:none">

@ -1,34 +1,12 @@
{% load tex_extras %} {% extends "members/tex_base.tex" %}
{% load static common tex_extras %}
\documentclass{article} {% block title %}{{ memberlist.title|esc_all }}{% endblock %}
{% block contact %}{% endblock %}
\usepackage[utf8]{inputenc} {% block content %}
% remove all undefined unicode characters instead of throwing an error
\makeatletter
\def\UTFviii@undefined@err#1{}
\makeatother
\usepackage{booktabs}
\usepackage{tabularx}
\usepackage{ragged2e}
\usepackage{amssymb}
\usepackage{cmbright}
\usepackage{graphicx}
\usepackage{textpos}
\usepackage[colorlinks]{hyperref}
\usepackage{float}
\usepackage[margin=1cm]{geometry}
\renewcommand{\arraystretch}{1.5} \begin{tabularx}{\textwidth}{l X}
\newcolumntype{Y}{>{\RaggedRight\arraybackslash}X}
\begin{document}
% HEADLINE
{\noindent\LARGE\textsc{ {{ memberlist.title|esc_all }} }}\\
\textit{Erstellt: {{ creation_date }} }\\
\begin{table}[H]
\begin{tabularx}{\textwidth}{@{} l l Y @{}}
\toprule \toprule
\textbf{Name} & \textbf{Kommentare} \\ \textbf{Name} & \textbf{Kommentare} \\
\midrule \midrule
@ -36,7 +14,6 @@
{{ m.member.name|esc_all }} & {{ m.comments_tex|esc_all }} \\ {{ m.member.name|esc_all }} & {{ m.comments_tex|esc_all }} \\
{% endfor %} {% endfor %}
\bottomrule \bottomrule
\end{tabularx} \end{tabularx}
\end{table}
\end{document} {% endblock %}

@ -1,32 +1,9 @@
{% load tex_extras %} {% extends "members/tex_base.tex" %}
{% load static common tex_extras %}
\documentclass[a4paper]{article} {% block title %}Teilnehmer*innenübersicht{% endblock %}
\usepackage[utf8]{inputenc} {% block content %}
% remove all undefined unicode characters instead of throwing an error
\makeatletter
\def\UTFviii@undefined@err#1{}
\makeatother
\usepackage{booktabs}
\usepackage{tabularx}
\usepackage{ragged2e}
\usepackage{amssymb}
\usepackage{cmbright}
\usepackage{graphicx}
\usepackage{textpos}
\usepackage[colorlinks]{hyperref}
\usepackage{float}
\usepackage[margin=1cm]{geometry}
\usepackage{ltablex}
\renewcommand{\arraystretch}{1.5}
\newcolumntype{Y}{>{\RaggedRight\arraybackslash}p{0.4\linewidth}}
\begin{document}
% HEADLINE
{\noindent\LARGE{Teilnehmer*innenübersicht}}\\[1mm]
\textit{Erstellt: {{ creation_date }} }\\
% DESCRIPTION % DESCRIPTION
\begin{table}[H] \begin{table}[H]
@ -40,20 +17,21 @@
\end{tabular} \end{tabular}
\end{table} \end{table}
\begin{tabularx}{1\linewidth}{ l l l Y} \begin{tabularx}{.97\linewidth}{lXXX}
\toprule \toprule
\# & \textbf{Name} & \textbf{Fähigkeiten (max. 100)} & \textbf{Kommentare} \\ \# & \textbf{Name} & \textbf{Fähigkeiten (max. 100)} & \textbf{Kommentare} \\
\midrule \midrule
\endhead
\bottomrule
\endfoot
{% for p in people %} {% for p in people %}
{{ forloop.counter }} & {{ p.name|esc_all }} & {{ p.qualities|esc_all }} & {{ p.comments|esc_all }} \\ {{ forloop.counter }} & {{ p.name|esc_all }} & {{ p.qualities|esc_all }} & {{ p.comments|esc_all }} \\
{% endfor %} {% endfor %}
\bottomrule
\end{tabularx} \end{tabularx}
\noindent{\large Fähigkeiten der Gruppe}\\
\noindent\large Fähigkeiten der Gruppe\\ \begin{tabularx}{.97\linewidth}{Xlll}
\begin{table}[H]
\begin{tabular*}{1\linewidth}{@{\extracolsep{\fill}}llll}
\toprule \toprule
\textbf{Name} & \textbf{Durchschnitt} & \textbf{Minimum} & \textbf{Maximum} \\ \textbf{Name} & \textbf{Durchschnitt} & \textbf{Minimum} & \textbf{Maximum} \\
\midrule \midrule
@ -61,9 +39,6 @@
{{ skill.name|esc_all }} & {{ skill.skill_avg|esc_all }} & {{ skill.skill_min|esc_all }} & {{ skill.skill_max|esc_all }} \\ {{ skill.name|esc_all }} & {{ skill.skill_avg|esc_all }} & {{ skill.skill_min|esc_all }} & {{ skill.skill_max|esc_all }} \\
{% endfor %} {% endfor %}
\bottomrule \bottomrule
\end{tabular*} \end{tabularx}
\end{table}
\vspace{1cm}
\end{document} {% endblock %}

@ -1,80 +1,9 @@
{% extends "members/tex_base.tex" %}
{% load static common tex_extras %} {% load static common tex_extras %}
\documentclass[a4paper]{article} {% block title %}Seminarbericht{% endblock %}
\usepackage[utf8]{inputenc} {% block content %}
% remove all undefined unicode characters instead of throwing an error
\makeatletter
\def\UTFviii@undefined@err#1{}
\makeatother
\usepackage{booktabs}
\usepackage{amssymb}
\usepackage{cmbright}
\usepackage{graphicx}
\usepackage{textpos}
\usepackage[colorlinks, breaklinks]{hyperref}
\usepackage{float}
\usepackage[margin=1in]{geometry}
\usepackage{array}
\usepackage{ragged2e}
\usepackage{tabularx}
\usepackage{titlesec}
\usepackage{ltablex}
\titleformat{\section}
{\Large\slshape}{\thesection\;}
{0em}{}
\newcommand{\picpos}[4]{
\begin{textblock*}{#1}(#2, #3)
\includegraphics[width=\textwidth]{#4}
\end{textblock*}
}
% custom url command for properly formatting emails
\DeclareUrlCommand\Email{\urlstyle{same}}
% allow linebreak after every character
\expandafter\def\expandafter\UrlBreaks\expandafter{\UrlBreaks
\do\/\do\a\do\b\do\c\do\d\do\e\do\f\do\g\do\h\do\i\do\j\do\k
\do\l\do\m\do\n\do\o\do\p\do\q\do\r\do\s\do\t\do\u\do\v
\do\w\do\x\do\y\do\z
\do\A\do\B\do\C\do\D\do\E\do\F\do\G\do\H\do\I\do\J\do\K
\do\L\do\M\do\N\do\O\do\P\do\Q\do\R\do\S\do\T\do\U\do\V
\do\W\do\X\do\Y\do\Z}
\renewcommand{\arraystretch}{1.5}
\newcolumntype{L}{>{\hspace{0pt}}X}
\newcolumntype{Y}{>{\RaggedRight\arraybackslash}X}
\newcommand{\tickedbox}{
\makebox[0pt][l]{$\square$}\raisebox{.15ex}{\hspace{0.1em}$\checkmark$}
}
\newcommand{\checkbox}{
\makebox[0pt][l]{$\square$}
}
\begin{document}
% HEADER RIGHT
{% settings_value 'DEFAULT_STATIC_PATH' as static_root %}
\picpos{4.5cm}{11.5cm}{0cm}{%
{{ static_root }}/general/img/dav_logo_sektion.png%
}
\begin{textblock*}{5cm}(11.5cm, 2.3cm)
\begin{flushright}
\small
\noindent Deutscher Alpenverein e. V. \\
Sektion {{ settings.SEKTION }} \\
{{ settings.SEKTION_STREET }} \\
{{ settings.SEKTION_TOWN }} \\
Tel.: {{ settings.SEKTION_TELEPHONE }} \\
Fax: {{ settings.SEKTION_TELEFAX }} \\
{{ settings.RESPONSIBLE_MAIL }} \\
\end{flushright}
\end{textblock*}
% HEADLINE
{\noindent\LARGE{Seminarbericht}}\\[1mm]
\textit{Erstellt: {{ creation_date }} }\\
% DESCRIPTION TABLE % DESCRIPTION TABLE
\begin{table}[H] \begin{table}[H]
@ -89,54 +18,14 @@
\end{tabular} \end{tabular}
\end{table} \end{table}
{% if mode == 'full' %}
{% if memberlist.ljpproposal %}
\section{Alpintechnische Ziele}
{{ memberlist.ljpproposal.goals_alpinistic|esc_all }}
\section{Pädagogische Ziele}
{{ memberlist.ljpproposal.goals_pedagogic|esc_all }}
\section{Inhalte und Methoden}
{{ memberlist.ljpproposal.methods|esc_all }}
\section{Wertung}
{{ memberlist.ljpproposal.evaluation|esc_all }}
\section{Erfahrungen und Verbesserungsvorschläge}
{{ memberlist.ljpproposal.experiences|esc_all }}
\section{Zeitlicher und methodischer Ablauf}
\begin{table}[H]
\begin{tabularx}{1\linewidth}{@{}l l Y @{}}
\toprule
\textbf{Zeitpunkt} & \textbf{Dauer} & \textbf{Art der Aktion inkl. Methode} \\
\midrule
{% for intervention in memberlist.ljpproposal.intervention_set.all %}
{{ intervention.date_start|datetime_short }} & {{ intervention.duration }} h & {{ intervention.activity|esc_all }} \\
{% endfor %}
\bottomrule
\end{tabularx}
\end{table}
{% endif %}
{% endif %}
\section{Teilnehmer*innenliste} \section{Teilnehmer*innenliste}
\begin{tabularx}{1\linewidth}{p{0.01\linewidth}>{\RaggedRight\arraybackslash}p{0.22\linewidth}>{\RaggedRight\arraybackslash}p{0.38\linewidth}p{0.14\linewidth}|c|c|c} \begin{tabularx}{.97\linewidth}{lp{0.2\textwidth}Xlccc}
\hline \toprule
\# & \textbf{Name} & \textbf{Anschrift} & \textbf{Geburtsdatum} & \textbf{m} & \textbf{w} & \textbf{d} \\ \# & \textbf{Name} & \textbf{Anschrift} & \textbf{Geburtsdatum} & \textbf{m} & \textbf{w} & \textbf{d} \\
\hline \midrule
\endhead \endhead
\hline \bottomrule
\endfoot \endfoot
{% for m in memberlist.membersonlist.all %} {% for m in memberlist.membersonlist.all %}
{{ forloop.counter }} & {{ m.member.name|esc_all }} & {{ m.member.address|esc_all }} & {{ m.member.birth_date_str|esc_all }} {{ forloop.counter }} & {{ m.member.name|esc_all }} & {{ m.member.address|esc_all }} & {{ m.member.birth_date_str|esc_all }}
@ -150,7 +39,7 @@
\section{Kosten} \section{Kosten}
\begin{tabularx}{1\linewidth}{@{\extracolsep{\fill}}Lr} \begin{tabularx}{.97\textwidth}{Xr}
\toprule \toprule
\textbf{Beschreibung} & \textbf{Betrag} \\ \textbf{Beschreibung} & \textbf{Betrag} \\
\midrule \midrule
@ -160,10 +49,11 @@
{% for bill in memberlist.statement.grouped_bills %} {% for bill in memberlist.statement.grouped_bills %}
{{ bill.short_description|esc_all }} & {{ bill.amount }}\\ {{ bill.short_description|esc_all }} & {{ bill.amount }}\\
{% endfor %} {% endfor %}
\midrule
Gesamt & {{ memberlist.statement.total_theoretic }}\\
\bottomrule \bottomrule
Gesamt & {{ memberlist.statement.total_theoretic }}\\
\end{tabularx} \end{tabularx}
{% endif %} {% endif %}
\end{document} {% endblock %}

@ -1,4 +1,4 @@
{% load tex_extras %} {% load tex_extras tz %}
\documentclass[a4paper]{article} \documentclass[a4paper]{article}
@ -36,7 +36,7 @@
\textbf{Sektion:} & {{ settings.SEKTION }} \\ \textbf{Sektion:} & {{ settings.SEKTION }} \\
\textbf{Titel der Maßnahme:} & {% if not memberlist.ljpproposal %}{{ memberlist.name|esc_all }}{% else %}{{ memberlist.ljpproposal.title }} {% endif %} \\ \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{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{tabular}
\end{table} \end{table}
@ -63,8 +63,8 @@
\textbf{Datum} & \textbf{Uhrzeit} & \multicolumn{4}{l}{\textbf{Art der Aktion}} & \textbf{Dauer} \\ \textbf{Datum} & \textbf{Uhrzeit} & \multicolumn{4}{l}{\textbf{Art der Aktion}} & \textbf{Dauer} \\
\midrule \midrule
{% for intervention in memberlist.ljpproposal.intervention_set.all %} {% for intervention in memberlist.ljpproposal.intervention_set.all %}
{{ intervention.date_start|date_short }} {{ intervention.date_start|localtime|date_short }}
& {{ intervention.date_start|time_short }} & {{ intervention.date_start|localtime|time_short }}
& \multicolumn{4}{l}{ {{ intervention.activity|esc_all }} } & \multicolumn{4}{l}{ {{ intervention.activity|esc_all }} }
& {{ intervention.duration }} h \\ & {{ intervention.duration }} h \\
{% endfor %} {% endfor %}

@ -0,0 +1,98 @@
{% load static common tex_extras %}
\documentclass[a4paper]{article}
\usepackage[utf8]{inputenc}
% remove all undefined unicode characters instead of throwing an error
\makeatletter
\def\UTFviii@undefined@err#1{}
\makeatother
\usepackage{booktabs}
\usepackage{amssymb}
\usepackage{cmbright}
\usepackage{graphicx}
\usepackage{textpos}
\usepackage[colorlinks, breaklinks]{hyperref}
\usepackage{float}
\usepackage[margin=2cm]{geometry}
\usepackage{array}
\usepackage{tabularx}
\usepackage{ltablex}
\usepackage{ragged2e}
\usepackage{titlesec}
\keepXColumns
\titleformat{\section}
{\Large\slshape}{\thesection\;}
{0em}{}
\newcommand{\picpos}[4]{
\begin{textblock*}{#1}(#2, #3)
\includegraphics[width=\textwidth]{#4}
\end{textblock*}
}
% custom url command for properly formatting emails
\DeclareUrlCommand\Email{\urlstyle{same}}
% allow linebreak after every character
\expandafter\def\expandafter\UrlBreaks\expandafter{\UrlBreaks
\do\/\do\a\do\b\do\c\do\d\do\e\do\f\do\g\do\h\do\i\do\j\do\k
\do\l\do\m\do\n\do\o\do\p\do\q\do\r\do\s\do\t\do\u\do\v
\do\w\do\x\do\y\do\z
\do\A\do\B\do\C\do\D\do\E\do\F\do\G\do\H\do\I\do\J\do\K
\do\L\do\M\do\N\do\O\do\P\do\Q\do\R\do\S\do\T\do\U\do\V
\do\W\do\X\do\Y\do\Z}
\renewcommand{\arraystretch}{1.5}
\newcolumntype{L}{>{\hspace{0pt}\raggedright\arraybackslash}X}
\newcolumntype{S}{>{\raggedright\arraybackslash\hsize=0.7\hsize}X}
\newcolumntype{Y}{>{\RaggedRight\arraybackslash}p{0.4\linewidth}}
\newcommand{\tickedbox}{
\makebox[0pt][l]{$\square$}\raisebox{.15ex}{\hspace{0.1em}$\checkmark$}
}
\newcommand{\checkbox}{
\makebox[0pt][l]{$\square$}
}
{% block extra-preamble %}
{% endblock extra-preamble %}
\begin{document}
{% block contact %}
% HEADER RIGHT
{% settings_value 'DEFAULT_STATIC_PATH' as static_root %}
\picpos{4.5cm}{11.7cm}{0cm}{%
{{ static_root }}/general/img/dav_logo_sektion.png%
}
\begin{textblock*}{5cm}(11.7cm, 2.3cm)
\begin{flushright}
\small
\noindent Deutscher Alpenverein e. V. \\
Sektion {{ settings.SEKTION }} \\
{{ settings.SEKTION_STREET }} \\
{{ settings.SEKTION_TOWN }} \\
Tel.: {{ settings.SEKTION_TELEPHONE }} \\
Fax: {{ settings.SEKTION_TELEFAX }} \\
{{ settings.RESPONSIBLE_MAIL }} \\
\end{flushright}
\end{textblock*}
{% endblock contact %}
{% block headline %}
% HEADLINE
{\LARGE{\noindent {% block title %}{% endblock title %} }}\\[1mm]
\textit{Erstellt: {{ creation_date }} }\\
{% endblock headline %}
{% block content %}
{% endblock content %}
\end{document}

@ -3,19 +3,36 @@
{% load static %} {% load static %}
{% block title %} {% block title %}
{% if member.confirmed %}
{% trans "Echo" %}
{% else %}
{% trans "Registration" %} {% trans "Registration" %}
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if member.confirmed %}
<h1>{% trans "Echo" %}</h1>
{% else %}
<h1>{% trans "Register" %}</h1> <h1>{% trans "Register" %}</h1>
{% endif %}
{% url 'members:download_registration_form' as download_url %} {% url 'members:download_registration_form' as download_url %}
{% if member.confirmed %}
<p>{% blocktrans %}Thank you for echoing back, your data was updated. For legal
reasons, we also need a signed participation form. In your case, a recent
participation form is missing. Please <a href="{{ download_url }}?key={{ key }}">download</a>
the pre-filled form, fill in the remaining fields and read the general conditions. If you agree,
please sign the document and upload a scan or image here.{% endblocktrans %}
</p>
{% else %}
<p>{% blocktrans %}We summarized your registration in our registration <p>{% blocktrans %}We summarized your registration in our registration
form. Please <a href="{{ download_url }}?key={{ key }}">download</a> it, form. Please <a href="{{ download_url }}?key={{ key }}">download</a> it,
fill in the remaining fields and read the general conditions. If you agree, fill in the remaining fields and read the general conditions. If you agree,
please sign the document and upload a scan or image here.{% endblocktrans %} please sign the document and upload a scan or image here.{% endblocktrans %}
</p> </p>
{% endif %}
<p>{% blocktrans %}If you are not an adult yet, please let someone responsible for you sign the agreement.{% endblocktrans %} <p>{% blocktrans %}If you are not an adult yet, please let someone responsible for you sign the agreement.{% endblocktrans %}
</p> </p>

@ -3,14 +3,26 @@
{% load static %} {% load static %}
{% block title %} {% block title %}
{% if member.confirmed %}
{% trans "Echo" %}
{% else %}
{% trans "Registration" %} {% trans "Registration" %}
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if member.confirmed %}
<h1>{% trans "Echo" %}</h1>
{% else %}
<h1>{% trans "Register" %}</h1> <h1>{% trans "Register" %}</h1>
{% endif %}
<p>{% blocktrans %}Thank you for uploading the registration form. Our team will process your registration shortly.{% endblocktrans %} <p>
{% blocktrans %}Thank you for uploading the registration form.{% endblocktrans %}
{% if not member.confirmed %}
{% blocktrans %}Our team will process your registration shortly.{% endblocktrans %}
{% endif %}
</p> </p>
{% endblock %} {% endblock %}

@ -1354,7 +1354,7 @@ class UploadRegistrationFormViewTestCase(BasicMemberTestCase):
}) })
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, self.assertContains(response,
_("Thank you for uploading the registration form. Our team will process your registration shortly.")) _("Our team will process your registration shortly."))
class DownloadRegistrationFormViewTestCase(BasicMemberTestCase): class DownloadRegistrationFormViewTestCase(BasicMemberTestCase):
def setUp(self): def setUp(self):
@ -1597,6 +1597,9 @@ class EchoViewTestCase(BasicMemberTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.key = self.fritz.generate_echo_key() self.key = self.fritz.generate_echo_key()
file = SimpleUploadedFile("form.pdf", b"file_content", content_type="application/pdf")
self.fritz.registration_form = file
self.fritz.save()
def _assert_failed(self, response): def _assert_failed(self, response):
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
@ -1638,7 +1641,7 @@ class EchoViewTestCase(BasicMemberTestCase):
url = reverse('members:echo') url = reverse('members:echo')
response = self.client.post(url, data={'key': self.key, 'password': self.fritz.echo_password}) response = self.client.post(url, data={'key': self.key, 'password': self.fritz.echo_password})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Thanks for echoing back. Here is your current data:')) self.assertContains(response, _('Here is your current data. Please check if it is up to date and change accordingly.'))
def test_post_save(self): def test_post_save(self):
url = reverse('members:echo') url = reverse('members:echo')
@ -1650,7 +1653,7 @@ class EchoViewTestCase(BasicMemberTestCase):
save='', save='',
)) ))
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Thanks for echoing back. Here is your current data:')) self.assertContains(response, _('Here is your current data. Please check if it is up to date and change accordingly.'))
# provide everything correctly # provide everything correctly
url = reverse('members:echo') url = reverse('members:echo')

@ -12,6 +12,7 @@ urlpatterns = [
re_path(r'^waitinglist/confirm', views.confirm_waiting , name='confirm_waiting'), re_path(r'^waitinglist/confirm', views.confirm_waiting , name='confirm_waiting'),
re_path(r'^waitinglist/leave', views.leave_waitinglist , name='leave_waitinglist'), re_path(r'^waitinglist/leave', views.leave_waitinglist , name='leave_waitinglist'),
re_path(r'^waitinglist/invitation/reject', views.reject_invitation , name='reject_invitation'), re_path(r'^waitinglist/invitation/reject', views.reject_invitation , name='reject_invitation'),
re_path(r'^waitinglist/invitation/confirm', views.confirm_invitation , name='confirm_invitation'),
re_path(r'^waitinglist', views.register_waiting_list , name='register_waiting_list'), re_path(r'^waitinglist', views.register_waiting_list , name='register_waiting_list'),
re_path(r'^mail/confirm', views.confirm_mail , name='confirm_mail'), re_path(r'^mail/confirm', views.confirm_mail , name='confirm_mail'),
] ]

@ -180,7 +180,13 @@ def echo(request):
# member.echo_key, member.echo_expire = "", timezone.now() # member.echo_key, member.echo_expire = "", timezone.now()
member.echoed = True member.echoed = True
member.save() member.save()
return render_echo_success(request, member.prename) if not member.registration_form:
# If the member does not have a registration form, forward them to the upload page.
member.generate_upload_registration_form_key()
member.send_upload_registration_form_link()
return HttpResponseRedirect(reverse('members:upload_registration_form') + "?key=" + member.upload_registration_form_key)
else:
return render_echo_success(request, member.prename)
except ValueError: except ValueError:
# when input is invalid # when input is invalid
form = MemberForm(request.POST) form = MemberForm(request.POST)
@ -305,7 +311,7 @@ def download_registration_form(request):
return render_upload_registration_form_invalid(request) return render_upload_registration_form_invalid(request)
key = request.GET['key'] key = request.GET['key']
try: try:
member = MemberUnconfirmedProxy.objects.get(upload_registration_form_key=key) member = Member.all_objects.get(upload_registration_form_key=key)
return render_download_registration_form(request, member) return render_download_registration_form(request, member)
except Member.DoesNotExist: except Member.DoesNotExist:
return render_upload_registration_form_invalid(request) return render_upload_registration_form_invalid(request)
@ -332,7 +338,7 @@ def upload_registration_form(request):
return render_upload_registration_form_invalid(request) return render_upload_registration_form_invalid(request)
key = request.GET['key'] key = request.GET['key']
try: try:
member = MemberUnconfirmedProxy.objects.get(upload_registration_form_key=key) member = Member.all_objects.get(upload_registration_form_key=key)
except Member.DoesNotExist: except Member.DoesNotExist:
return render_upload_registration_form_invalid(request) return render_upload_registration_form_invalid(request)
form = UploadRegistrationForm(instance=member) form = UploadRegistrationForm(instance=member)
@ -341,7 +347,7 @@ def upload_registration_form(request):
return render_upload_registration_form_invalid(request) return render_upload_registration_form_invalid(request)
key = request.POST['key'] key = request.POST['key']
try: try:
member = MemberUnconfirmedProxy.objects.get(upload_registration_form_key=key) member = Member.all_objects.get(upload_registration_form_key=key)
except Member.DoesNotExist: except Member.DoesNotExist:
return render_upload_registration_form_invalid(request) return render_upload_registration_form_invalid(request)
@ -470,15 +476,56 @@ def reject_invitation(request):
except InvitationToGroup.DoesNotExist: except InvitationToGroup.DoesNotExist:
return render_reject_invalid(request) return render_reject_invalid(request)
if 'reject_invitation' in request.POST: if 'reject_invitation' in request.POST:
invitation.rejected = True invitation.reject()
invitation.save()
return render_reject_success(request, invitation) return render_reject_success(request, invitation)
elif 'leave_waitinglist' in request.POST: elif 'leave_waitinglist' in request.POST:
invitation.notify_left_waitinglist()
invitation.waiter.unregister() invitation.waiter.unregister()
return render_reject_success(request, invitation, leave_waitinglist=True) return render_reject_success(request, invitation, leave_waitinglist=True)
return render_reject_invalid(request) return render_reject_invalid(request)
def render_confirm_invitation(request, invitation):
return render(request, 'members/confirm_invitation.html',
{'invitation': invitation,
'groupname': invitation.group.name,
'contact_email': invitation.group.contact_email,
'timeinfo': invitation.group.get_time_info()})
def render_confirm_invalid(request):
return render(request, 'members/confirm_invalid.html')
def render_confirm_success(request, invitation):
return render(request, 'members/confirm_success.html',
{'invitation': invitation,
'groupname': invitation.group.name,
'contact_email': invitation.group.contact_email,
'timeinfo': invitation.group.get_time_info()})
def confirm_invitation(request):
if request.method == 'GET' and 'key' in request.GET:
key = request.GET['key']
try:
invitation = InvitationToGroup.objects.get(key=key)
if invitation.rejected or invitation.is_expired():
raise ValueError
return render_confirm_invitation(request, invitation)
except (ValueError, InvitationToGroup.DoesNotExist):
return render_confirm_invalid(request)
if request.method != 'POST' or 'key' not in request.POST:
return render_confirm_invalid(request)
key = request.POST['key']
try:
invitation = InvitationToGroup.objects.get(key=key)
except InvitationToGroup.DoesNotExist:
return render_confirm_invalid(request)
invitation.confirm()
return render_confirm_success(request, invitation)
def confirm_waiting(request): def confirm_waiting(request):
if request.method == 'GET' and 'key' in request.GET: if request.method == 'GET' and 'key' in request.GET:
key = request.GET['key'] key = request.GET['key']

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

@ -72,9 +72,11 @@ def normalize_name(raw, nospaces=True, noumlaut=True):
return unicodedata.normalize('NFKD', raw).encode('ascii', 'ignore').decode('ascii') return unicodedata.normalize('NFKD', raw).encode('ascii', 'ignore').decode('ascii')
def normalize_filename(filename, append_date=True): def normalize_filename(filename, append_date=True, date=None):
if append_date: if append_date and not date:
filename = filename + "_" + datetime.today().strftime("%d_%m_%Y") date = datetime.today()
if date:
filename = filename + "_" + date.strftime("%d_%m_%Y")
filename = filename.replace(' ', '_').replace('&', '').replace('/', '_') filename = filename.replace(' ', '_').replace('&', '').replace('/', '_')
# drop umlauts, accents etc. # drop umlauts, accents etc.
return unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode() return unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()

Loading…
Cancel
Save