From 8b03deda5bea524153704f18a58d27a82ef04ab9 Mon Sep 17 00:00:00 2001
From: Christian Merten
Date: Fri, 7 Feb 2025 22:06:14 +0100
Subject: [PATCH] members/admin: adapt LJP application to new format (#121)
Implements the new LJP application format as outlined in #116. In particular:
- The V-32 PDF is replaced by an `.xlsx` sheet.
- The LaTeX generated PDF for the seminar report is replaced by a `.docx` file, generated by `pandoc` from a modified `.tex` file.
- The cost and participants overview from the old PDF can still be generated separately, but is no longer required.
Also adds many fields to `LJPProposal` that are required to generate the full application.
---
docker/development/Dockerfile | 4 +-
docker/production/Dockerfile | 4 +-
docker/test/Dockerfile | 4 +-
jdav_web/contrib/media.py | 11 +
jdav_web/members/admin.py | 102 +++---
jdav_web/members/excel.py | 66 +++-
.../members/locale/de/LC_MESSAGES/django.po | 321 +++++++++++++-----
.../migrations/0035_ljp_application_rework.py | 68 ++++
jdav_web/members/models.py | 56 ++-
jdav_web/members/pdf.py | 34 +-
.../admin/generate_seminar_report.html | 67 ++--
.../templates/members/LJP_VBK_3-1.xlsx | Bin 0 -> 17651 bytes
.../templates/members/LJP_VBK_3-2.xlsx | Bin 0 -> 17648 bytes
.../templates/members/seminar_report.tex | 2 +-
.../templates/members/seminar_report_docx.tex | 71 ++++
jdav_web/members/templatetags/tex_extras.py | 10 +
jdav_web/members/tests.py | 61 +++-
requirements.txt | 1 +
18 files changed, 688 insertions(+), 194 deletions(-)
create mode 100644 jdav_web/members/migrations/0035_ljp_application_rework.py
create mode 100644 jdav_web/members/templates/members/LJP_VBK_3-1.xlsx
create mode 100644 jdav_web/members/templates/members/LJP_VBK_3-2.xlsx
create mode 100644 jdav_web/members/templates/members/seminar_report_docx.tex
diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile
index ca0cf04..da56878 100644
--- a/docker/development/Dockerfile
+++ b/docker/development/Dockerfile
@@ -1,7 +1,7 @@
-FROM python:3.9-bullseye
+FROM python:3.9-bookworm
# install additional dependencies
-RUN apt-get update && apt-get install -y gettext texlive texlive-fonts-extra
+RUN apt-get update && apt-get install -y gettext texlive texlive-fonts-extra pandoc
WORKDIR /app
diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile
index bf51753..47f31d4 100644
--- a/docker/production/Dockerfile
+++ b/docker/production/Dockerfile
@@ -1,7 +1,7 @@
-FROM python:3.9-bullseye
+FROM python:3.9-bookworm
# install additional dependencies
-RUN apt-get update && apt-get install -y gettext texlive texlive-fonts-extra
+RUN apt-get update && apt-get install -y gettext texlive texlive-fonts-extra pandoc
# create user
RUN groupadd -g 501 app && useradd -g 501 -u 501 -m -d /app app
diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile
index a4de64d..3005167 100644
--- a/docker/test/Dockerfile
+++ b/docker/test/Dockerfile
@@ -1,7 +1,7 @@
-FROM python:3.9-bullseye
+FROM python:3.9-bookworm
# install additional dependencies
-RUN apt-get update && apt-get install -y gettext texlive texlive-fonts-extra
+RUN apt-get update && apt-get install -y gettext texlive texlive-fonts-extra pandoc
# create user
RUN groupadd -g 501 app && useradd -g 501 -u 501 -m -d /app app
diff --git a/jdav_web/contrib/media.py b/jdav_web/contrib/media.py
index 4ff27c9..d5e873b 100644
--- a/jdav_web/contrib/media.py
+++ b/jdav_web/contrib/media.py
@@ -1,9 +1,20 @@
import os
from django.conf import settings
from django.http import HttpResponse
+from django import template
+from django.template.loader import get_template
from wsgiref.util import FileWrapper
+def find_template(template_name):
+ for engine in template.engines.all():
+ for loader in engine.engine.template_loaders:
+ for origin in loader.get_template_sources(template_name):
+ if os.path.exists(origin.name):
+ return origin.name
+ raise template.TemplateDoesNotExist(f"Could not find template: {template_name}")
+
+
def media_path(fp):
return os.path.join(os.path.join(settings.MEDIA_ROOT, "memberlists"), fp)
diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py
index 3c761f9..3dead73 100644
--- a/jdav_web/members/admin.py
+++ b/jdav_web/members/admin.py
@@ -28,8 +28,8 @@ from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\
from django.forms import Textarea, RadioSelect, TypedChoiceField, CheckboxInput
from django.shortcuts import render
from django.core.exceptions import PermissionDenied, ValidationError
-from .pdf import render_tex, fill_pdf_form, merge_pdfs, serve_pdf
-from .excel import generate_group_overview
+from .pdf import render_tex, fill_pdf_form, merge_pdfs, serve_pdf, render_docx
+from .excel import generate_group_overview, generate_ljp_vbk
from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin
@@ -844,7 +844,7 @@ class GroupAdmin(CommonAdminMixin, admin.ModelAdmin):
class ActivityCategoryAdmin(admin.ModelAdmin):
- fields = ['name', 'description']
+ fields = ['name', 'ljp_category', 'description']
class FreizeitAdminForm(forms.ModelForm):
@@ -1037,14 +1037,6 @@ class MemberNoteListAdmin(admin.ModelAdmin):
summary.short_description = _('Generate PDF summary')
-class GenerateSeminarReportForm(forms.Form):
- modes = (('full', _('Full report')),
- ('basic', _('Costs and participants only')))
- mode = forms.ChoiceField(choices=modes, label=_('Mode'))
- prepend_v32 = forms.BooleanField(label=_('Prepend V32'), initial=True,
- widget=CheckboxInput(attrs={'style': 'display: inherit'}),
- required=False)
-
class GenerateSjrForm(forms.Form):
def __init__(self, *args, **kwargs):
@@ -1054,6 +1046,23 @@ class GenerateSjrForm(forms.Form):
self.fields['invoice'] = forms.ChoiceField(choices=self.attachments, label=_('Invoice'))
+def decorate_download(fun):
+ def aux(self, request, object_id):
+ try:
+ memberlist = Freizeit.objects.get(pk=object_id)
+ except Freizeit.DoesNotExist:
+ messages.error(request, _('Excursion not found.'))
+ return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
+ if not self.may_view_excursion(request, memberlist):
+ return self.not_allowed_view(request, memberlist)
+ if not hasattr(memberlist, 'ljpproposal'):
+ messages.error(request, _('This excursion does not have a LJP proposal. Please add one and try again.'))
+ return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name),
+ args=(memberlist.pk,)))
+ return fun(self, request, memberlist)
+ return aux
+
+
class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
#inlines = [MemberOnListInline, LJPOnListInline, StatementOnListInline]
form = FreizeitAdminForm
@@ -1064,8 +1073,8 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
view_on_site = False
fieldsets = (
(None, {
- 'fields': ('name', 'place', 'destination', 'date', 'end', 'description', 'groups', 'jugendleiter',
- 'approved_extra_youth_leader_count',
+ 'fields': ('name', 'place', 'postcode', 'destination', 'date', 'end', 'description', 'groups',
+ 'jugendleiter', 'approved_extra_youth_leader_count',
'tour_type', 'tour_approach', 'kilometers_traveled', 'activity', 'difficulty'),
'description': _('General information on your excursion. These are partly relevant for the amount of financial compensation (means of transport, travel distance, etc.).')
}),
@@ -1115,40 +1124,36 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
return render_tex(memberlist.name + "_Notizen", 'members/notes_list.tex', context)
notes_list.short_description = _('Generate overview')
- def render_seminar_report_options(self, request, memberlist, form):
+ @decorate_download
+ def download_seminar_vbk(self, request, memberlist):
+ fp = generate_ljp_vbk(memberlist)
+ return serve_media(fp, 'application/xlsx')
+
+ @decorate_download
+ def download_seminar_report_docx(self, request, memberlist):
+ title = memberlist.ljpproposal.title
+ context = dict(memberlist=memberlist, settings=settings)
+ return render_docx(title + '_Seminarbericht', 'members/seminar_report_docx.tex', context)
+
+ @decorate_download
+ def download_seminar_report_costs_and_participants(self, request, memberlist):
+ title = memberlist.ljpproposal.title
+ context = dict(memberlist=memberlist, settings=settings)
+ return render_tex(title + '_Seminarbericht', 'members/seminar_report.tex', context)
+
+ def seminar_report(self, request, memberlist):
+ if not self.may_view_excursion(request, memberlist):
+ return self.not_allowed_view(request, memberlist)
+ if not hasattr(memberlist, 'ljpproposal'):
+ messages.error(request, _('This excursion does not have a LJP proposal. Please add one and try again.'))
+ return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name),
+ args=(memberlist.pk,)))
context = dict(self.admin_site.each_context(request),
title=_('Generate seminar report'),
opts=self.opts,
memberlist=memberlist,
- form=form,
object=memberlist)
return render(request, 'admin/generate_seminar_report.html', context=context)
-
- def seminar_report(self, request, memberlist):
- if not self.may_view_excursion(request, memberlist):
- return self.not_allowed_view(request, memberlist)
- if "apply" in request.POST:
- form = GenerateSeminarReportForm(request.POST)
- if not form.is_valid():
- messages.error(request, _('Please select a mode.'))
- return self.render_seminar_report_options(request, memberlist, form)
- mode = form.cleaned_data['mode']
- prepend_v32 = form.cleaned_data['prepend_v32']
- if mode == 'full' and not hasattr(memberlist, 'ljpproposal'):
- messages.error(request, _('Full mode is only available, if the seminar report section is filled out.'))
- return self.render_seminar_report_options(request, memberlist, form)
- title = memberlist.ljpproposal.title if hasattr(memberlist, 'ljpproposal') else memberlist.name
- context = dict(memberlist=memberlist, settings=settings, mode=mode)
- fp = render_tex(title + '_Seminarbericht', 'members/seminar_report.tex', context, save_only=True)
- if prepend_v32:
- context = memberlist.v32_fields()
- v32_fp = fill_pdf_form(title + "_LJP_V32",
- 'members/V32-1_Themenorientierte_Bildungsmassnahmen.pdf',
- context,
- save_only=True)
- return merge_pdfs(title + '_LJP_Antrag', [v32_fp, fp])
- return serve_pdf(fp)
- return self.render_seminar_report_options(request, memberlist, GenerateSeminarReportForm())
seminar_report.short_description = _('Generate seminar report')
def render_sjr_options(self, request, memberlist, form):
@@ -1226,12 +1231,27 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin):
wrap(self.action_view),
name="%s_%s_action" % (self.opts.app_label, self.opts.model_name),
),
+ path(
+ "/download/ljp_vbk",
+ wrap(self.download_seminar_vbk),
+ name="%s_%s_download_ljp_vbk" % (self.opts.app_label, self.opts.model_name),
+ ),
+ path("/download/ljp_report_docx",
+ wrap(self.download_seminar_report_docx),
+ name="%s_%s_download_ljp_report_docx" % (self.opts.app_label, self.opts.model_name),
+ ),
+ path("/download/ljp_report_costs_and_participants",
+ wrap(self.download_seminar_report_costs_and_participants),
+ name="%s_%s_download_ljp_costs_participants" % (self.opts.app_label, self.opts.model_name),
+ ),
]
return custom_urls + urls
def action_view(self, request, object_id):
if "sjr_application" in request.POST:
return self.sjr_application(request, Freizeit.objects.get(pk=object_id))
+ if "seminar_vbk" in request.POST:
+ return self.seminar_vbk(request, Freizeit.objects.get(pk=object_id))
if "seminar_report" in request.POST:
return self.seminar_report(request, Freizeit.objects.get(pk=object_id))
if "notes_list" in request.POST:
diff --git a/jdav_web/members/excel.py b/jdav_web/members/excel.py
index 6fa8ff7..3f4c154 100644
--- a/jdav_web/members/excel.py
+++ b/jdav_web/members/excel.py
@@ -1,9 +1,10 @@
from datetime import datetime
import os
import xlsxwriter
+import openpyxl
from django.conf import settings
-from contrib.media import media_path
-from .models import WEEKDAYS
+from contrib.media import media_path, find_template
+from .models import WEEKDAYS, LJPProposal
def generate_group_overview(all_groups, limit_to_public = True):
"""
@@ -67,3 +68,64 @@ def generate_group_overview(all_groups, limit_to_public = True):
workbook.close()
return filename
+
+
+VBK_TEMPLATES = {
+ LJPProposal.LJP_STAFF_TRAINING: 'members/LJP_VBK_3-1.xlsx',
+ LJPProposal.LJP_EDUCATIONAL: 'members/LJP_VBK_3-2.xlsx',
+}
+
+NOT_BW_REASONS = {
+ LJPProposal.NOT_BW_CONTENT: 'aufgrund der Lehrgangsinhalte',
+ LJPProposal.NOT_BW_ROOMS: 'trägereigene Räumlichkeiten',
+ LJPProposal.NOT_BW_CLOSE_BORDER: 'Grenznähe',
+ LJPProposal.NOT_BW_ECONOMIC: 'wirtschaftliche Sparsamkeit',
+}
+
+LJP_GOALS = {
+ LJPProposal.LJP_QUALIFICATION: 'Qualifizierung',
+ LJPProposal.LJP_PARTICIPATION: 'Partizipation',
+ LJPProposal.LJP_DEVELOPMENT: 'Persönlichkeitsentwicklung',
+ LJPProposal.LJP_ENVIRONMENT: 'Umwelt',
+}
+
+
+def generate_ljp_vbk(excursion):
+ """
+ Generate the VBK forms for LJP given an excursion. Returns the filename to the filled excel file.
+ """
+ if not hasattr(excursion, 'ljpproposal'):
+ raise ValueError(f"Excursion has no LJP proposal.")
+ template_path = VBK_TEMPLATES[excursion.ljpproposal.category]
+ path = find_template(template_path)
+ workbook = openpyxl.load_workbook(path)
+
+ sheet = workbook.active
+ title = excursion.ljpproposal.title
+
+ sheet['I6'] = settings.SEKTION_IBAN
+ sheet['I8'] = settings.SEKTION_ACCOUNT_HOLDER
+ sheet['P3'] = excursion.end.year
+ sheet['B4'] = f"Sektion {settings.SEKTION}"
+ sheet['B5'] = settings.SEKTION_STREET
+ sheet['B6'] = settings.SEKTION_TOWN
+ sheet['B7'] = settings.RESPONSIBLE_MAIL
+ sheet['B36'] = f"{settings.SEKTION}, {datetime.today():%d.%m.%Y}"
+ sheet['F19'] = f"B {excursion.date:%y}-{excursion.pk}"
+ sheet['C19'] = LJP_GOALS[excursion.ljpproposal.goal] if excursion.ljpproposal.goal in LJP_GOALS else ""
+ sheet['D19'] = settings.SEKTION
+ sheet['G19'] = title
+ sheet['I19'] = f"von {excursion.date:%d.%m.%y} bis {excursion.end:%d.%m.%y}"
+ sheet['J19'] = excursion.duration
+ sheet['L19'] = f"{excursion.ljp_participant_count}"
+ sheet['H19'] = excursion.get_ljp_activity_category()
+ sheet['M19'] = f"{excursion.postcode}, {excursion.place}"
+ sheet['N19'] = f"{NOT_BW_REASONS[excursion.ljpproposal.not_bw_reason]}"\
+ if not excursion.ljpproposal.not_bw_reason is None else ""
+
+ if hasattr(excursion, 'statement'):
+ sheet['Q19'] = f"{excursion.statement.total_theoretic}"
+
+ filename = f"LJP_V-BK_3.{excursion.ljpproposal.category}_{title}.xlsx"
+ workbook.save(media_path(filename))
+ return filename
diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po
index fc719e6..c0adc32 100644
--- a/jdav_web/members/locale/de/LC_MESSAGES/django.po
+++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-02-02 00:32+0100\n"
+"POT-Creation-Date: 2025-02-06 00:32+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -336,24 +336,19 @@ msgid "Generate PDF summary"
msgstr "Übersicht erstellen"
#: members/admin.py
-msgid "Full report"
-msgstr "Vollständiger Seminarbericht"
-
-#: members/admin.py
-msgid "Costs and participants only"
-msgstr "Nur Kosten und Teilnehmende"
-
-#: members/admin.py
-msgid "Mode"
-msgstr "Modus"
+msgid "Invoice"
+msgstr "Beleg"
#: members/admin.py
-msgid "Prepend V32"
-msgstr "V32 Formblatt einfügen"
+msgid "Excursion not found."
+msgstr "Ausfahrt nicht gefunden."
#: members/admin.py
-msgid "Invoice"
-msgstr "Beleg"
+msgid ""
+"This excursion does not have a LJP proposal. Please add one and try again."
+msgstr ""
+"Diese Ausfahrt hat keinen Seminarbericht. Bitte füge einen hinzu und "
+"versuche es erneut."
#: members/admin.py
msgid ""
@@ -383,17 +378,6 @@ msgstr "Hinweise für Jugendleiter erstellen"
msgid "Generate seminar report"
msgstr "Landesjugendplan Antrag erstellen"
-#: members/admin.py members/tests.py
-msgid "Please select a mode."
-msgstr "Bitte wähle einen Modus aus."
-
-#: members/admin.py members/tests.py
-msgid ""
-"Full mode is only available, if the seminar report section is filled out."
-msgstr ""
-"Der vollständiger Modus ist nur verfügbar, wenn der Seminarbericht "
-"ausgefüllt ist. "
-
#: members/admin.py members/templates/admin/generate_sjr_application.html
msgid "Generate SJR application"
msgstr "SJR Antrag erstellen"
@@ -491,8 +475,7 @@ msgstr "LJP Spielart"
#: members/models.py
msgid ""
"The official category for LJP applications associated with this activity."
-msgstr ""
-"Die offizielle Spielart für LJP Anträge mit dieser Aktivität."
+msgstr "Die offizielle Spielart für LJP Anträge mit dieser Aktivität."
#: members/models.py
msgid "Description"
@@ -951,24 +934,92 @@ msgid "registration passwords"
msgstr "Registrierungspasswörter"
#: members/models.py
-msgid "Alpinistic goals"
-msgstr "Alpintechnische Ziele"
+msgid ""
+"Official title of your seminar, this can differ from the informal title. Use "
+"e.g. sports climbing course instead of climbing weekend for fun."
+msgstr ""
+"Offizieller Titel des Seminars, dieser weicht in der Regel vom informellen "
+"Titel ab. Verwende zum Beispiel Sportkletterkurs statt Kletterfreizeit."
+
+#: members/models.py
+msgid "Educational programme"
+msgstr "Themenorientierte Bildungsmaßnahme"
+
+#: members/models.py
+msgid "Staff training"
+msgstr "Jugendleiter*innenweiterbildung"
+
+#: members/models.py
+msgid "Category"
+msgstr "Kategorie"
+
+#: members/models.py
+msgid "Type of seminar. Usually the correct choice is educational programme."
+msgstr "Kurstyp. In der Regel Themenorientierte Bildungsmaßnahme."
+
+#: members/models.py
+msgid "Qualification"
+msgstr "Qualifizierung"
+
+#: members/models.py
+msgid "Participation"
+msgstr "Partizipation"
+
+#: members/models.py
+msgid "Personality development"
+msgstr "Persönlichkeitsentwicklung"
+
+#: members/models.py
+msgid "Environment"
+msgstr "Umwelt"
+
+#: members/models.py
+msgid "Learning goal"
+msgstr "Bildungsziel"
+
+#: members/models.py
+msgid "Official learning goal according to LJP regulations."
+msgstr "Offizielles Bildungsziel gemäß LJP Richtlinien."
+
+#: members/models.py
+msgid "Strategy"
+msgstr "Zielverfolgung- und Erreichung"
+
+#: members/models.py
+msgid ""
+"How do you want to reach the learning goal? Has the goal been reached? If "
+"not, why not? If yes, what helped you to reach the goal?"
+msgstr ""
+"Wie wolltet ihr das Bildungsziel erreichen? Ist das Ziel so erreicht worden? "
+"Wenn nicht, warum nicht? Wenn ja, was hat geholfen, das Ziel zu erreichen?"
+
+#: members/models.py
+msgid "Course content"
+msgstr "aufgrund der Lehrgangsinhalte"
#: members/models.py
-msgid "Pedagogic goals"
-msgstr "Pädagogische Ziele"
+msgid "Available rooms"
+msgstr "trägereigene Räumlichkeiten"
#: members/models.py
-msgid "Content and methods"
-msgstr "Inhalte und Methoden"
+msgid "Close to the border"
+msgstr "Grenznähe"
#: members/models.py
-msgid "Evaluation"
-msgstr "Wertung"
+msgid "Economic reasons"
+msgstr "wirtschaftliche Sparsamkeit"
#: members/models.py
-msgid "Experiences and possible improvements"
-msgstr "Erfahrungen und Verbesserungsvorschläge"
+msgid "Explanation if excursion not in Baden-Württemberg"
+msgstr "Begründung, falls Kursort nicht in Baden-Württemberg"
+
+#: members/models.py
+msgid ""
+"If the excursion takes place outside of Baden-Württemberg, please explain. "
+"Otherwise, leave this empty."
+msgstr ""
+"Falls die Ausfahrt außerhalb von Baden-Württemberg stattfindet, gib bitte "
+"eine Begründung an. Sonst lass dieses Feld frei."
#: members/models.py
msgid "LJP Proposal"
@@ -1046,10 +1097,6 @@ msgstr "Fortbildungstyp"
msgid "Training categories"
msgstr "Fortbildungstypen"
-#: members/models.py
-msgid "Category"
-msgstr "Kategorien"
-
#: members/models.py
msgid "Comments"
msgstr "Kommentar"
@@ -1097,7 +1144,6 @@ msgstr "Zurück auf die Warteliste setzen"
#: members/templates/admin/demote_to_waiter.html
#: members/templates/admin/freizeit_finance_overview.html
-#: members/templates/admin/generate_seminar_report.html
#: members/templates/admin/generate_sjr_application.html
#: members/templates/admin/invite_as_user.html
#: members/templates/admin/invite_for_group.html
@@ -1335,63 +1381,60 @@ msgstr ""
"schnellstmöglich auf dich zurück."
#: members/templates/admin/freizeit_finance_overview.html
+#: members/templates/admin/generate_seminar_report.html
#: members/templates/admin/invite_for_group_text.html
msgid "Back"
msgstr "Zurück"
#: members/templates/admin/generate_seminar_report.html
-msgid ""
-"Here you can generate a seminar report suitable for the LJP. A report\n"
-"always contains a head page with the basic information on the seminar."
-msgstr ""
-"Hier kannst du einen Zuschussantrag für den Landesjugendplan (LJP) "
-"erstellen. Ein solcher Antrag besteht immer aus zwei Teilen: Einem Formblatt "
-"und einem Seminarbericht. Ein Bericht enthält immer einen Kopf mit den "
-"Stammdaten des Seminars. Darüber hinaus muss der Seminarbericht eine "
-"Teilnehemendenliste, eine Kostenübersicht und eine detaillierte didaktische "
-"Planung enthalten. "
+msgid "LJP application for"
+msgstr "Landesjugendplan Antrag für"
#: members/templates/admin/generate_seminar_report.html
msgid ""
-"Expenses with same short description are automatically summed up and shown "
-"as one expense in the\n"
-"expense overview."
+"For applying for contributions by the LJP, a seminar report is required. "
+"From the information\n"
+"that you entered in the excursion, you can automatically generate such a "
+"report here."
msgstr ""
-"In der Kostenübersicht werden Ausgaben mit der gleichen Kurzbeschreibung "
-"automatisch aufsummiert und zu einer Ausgabe zusammengefasst."
+"Um Zuschüsse vom Landesjugendplan (LJP) zu beantragen, ist ein Antrag "
+"notwendig. Aus den Informationen, die du in der Ausfahrt angegeben hast, "
+"kann automatisch ein solcher Antrag erstellt werden."
#: members/templates/admin/generate_seminar_report.html
-msgid ""
-"Full report: Include learning goals and a detailed, tabularized time "
-"schedule. This requires\n"
-"the seminar report section to be filled out."
-msgstr ""
-"Vollständiger Bericht: Stelle Lernziele und einen detaillierten, "
-"tabellierten Zeitplan dar. Dies benötigt, dass der Seminarbericht in der "
-"Ausfahrt ausgefüllt ist."
+msgid "A seminar report consists of multiple components:"
+msgstr "Ein LJP Antrag besteht aus verschiedenen Komponenten:"
#: members/templates/admin/generate_seminar_report.html
msgid ""
-"Costs and participants only: Only show a list of participants and costs. In "
-"this case you\n"
-"have to add learning goals and a time schedule manually."
+"An excel sheet containing the basic data of the seminar. This is also called "
+"the V-BK form."
msgstr ""
-"Nur Kosten und Teilnehmende: Zeige nur eine Liste von Teilnehmenden und "
-"Kosten an. In diesem Fall musst du Lernziele und einen Zeitplan manuell "
-"hinzufügen."
+"Eine Excel Tabelle mit den Basisdaten des Kurses. Fachbegriff: V-BK Formular."
-#: members/templates/admin/generate_seminar_report.html members/tests.py
-msgid "You may also choose to include the V32 attachment."
+#: members/templates/admin/generate_seminar_report.html
+msgid "Download"
+msgstr "Herunterladen"
+
+#: members/templates/admin/generate_seminar_report.html
+msgid ""
+"A pedagocial report on the strategy and outcome of the seminar with respect "
+"to achieving the\n"
+"learning goal.\n"
+"This also includes a detailed, tabularized time schedule and is produced as "
+"an editable Microsoft Word document."
msgstr ""
-"Ein LJP Antrag benötigt immer ein Formblatt (in unserem Fall V32-1 "
-"Themenorientierte Bildungsmaßnahmen). Dieses kannst du automatisch "
-"vorausfüllen lassen und dem Antrag hinzufügen. Bitte fülle die verbleibenden "
-"Felder im Formblatt selbst aus und unterschreibe das PDF."
+"Ein pädagogischer Bericht zur Planung, zum Ablauf und zur Auswertung des "
+"Kurses. Dies beinhaltet auch einen detaillierten, tabellarischen, zeitlichen "
+"Ablauf und wird als veränderbares Word Dokument erstellt."
#: members/templates/admin/generate_seminar_report.html
-#: members/templates/admin/generate_sjr_application.html
-msgid "Generate"
-msgstr "Erstellen"
+msgid ""
+"A cost and participants overview. This is not required for the actual "
+"application, but is provided for convience as a PDF document."
+msgstr ""
+"Eine Kosten- und Teilnehmendenübersicht. Dies ist nicht notwendig für den "
+"eigentlichen Bericht, muss aber langfristig aufbewahrt werden."
#: members/templates/admin/generate_sjr_application.html members/tests.py
msgid "Here you can generate an allowance application for the SJR."
@@ -1410,6 +1453,10 @@ msgid ""
"Please send this application form to the jdav finance officer via email."
msgstr "Bitte sende diesen Antrag an den/die JDAV-Finanzwart*in per E-Mail."
+#: members/templates/admin/generate_sjr_application.html
+msgid "Generate"
+msgstr "Erstellen"
+
#: members/templates/admin/invite_as_user.html
#, python-format
msgid ""
@@ -1932,6 +1979,25 @@ msgstr ""
"Danke %(prename)s für dein Interesse auf der Warteliste zu bleiben.\n"
"Dein Platz wurde bestätigt."
+#: members/tests.py
+msgid "You may also choose to include the V32 attachment."
+msgstr ""
+"Ein LJP Antrag benötigt immer ein Formblatt (in unserem Fall V32-1 "
+"Themenorientierte Bildungsmaßnahmen). Dieses kannst du automatisch "
+"vorausfüllen lassen und dem Antrag hinzufügen. Bitte fülle die verbleibenden "
+"Felder im Formblatt selbst aus und unterschreibe das PDF."
+
+#: members/tests.py
+msgid "Please select a mode."
+msgstr "Bitte wähle einen Modus aus."
+
+#: members/tests.py
+msgid ""
+"Full mode is only available, if the seminar report section is filled out."
+msgstr ""
+"Der vollständiger Modus ist nur verfügbar, wenn der Seminarbericht "
+"ausgefüllt ist. "
+
#: members/tests.py
msgid "This field is required."
msgstr ""
@@ -1974,6 +2040,99 @@ msgstr "Optionale zusätzliche E-Mailadresse"
msgid "Invalid emergency contacts"
msgstr "Ungültige Notfallkontakte"
+#~ msgid "Generate LJP V-BK form"
+#~ msgstr "Erzeuge LJP V-BK Formular"
+
+#~ msgid ""
+#~ "Every LJP application needs a V-BK form containing the most important "
+#~ "facts about the seminar.\n"
+#~ "Here you can automatically generate such a form in Excel format."
+#~ msgstr ""
+#~ "Jeder LJP Antrag benötigt ein V-BK Formular, das die wichtigsten "
+#~ "Randdaten des Seminars enthält. Hier kannst du automatisch ein solches "
+#~ "Formular im Excel Format erstellen."
+
+#~ msgid ""
+#~ "Your excursion currently has no cost-plan attached, hence the total costs "
+#~ "can't be automatically\n"
+#~ "calculated and added to the form."
+#~ msgstr ""
+#~ "Deine Ausfahrt hat zur Zeit keinen Kostenplan. Daher können die "
+#~ "Gesamtkosten nicht automatisch berechnet und dem Formular hinzugefügt "
+#~ "werden."
+
+#~ msgid ""
+#~ "Depending on the type of seminar, please select one of the two options "
+#~ "below."
+#~ msgstr "Bitte wähle aus, um welche Art von Seminar es sich handelt."
+
+#~ msgid "Full report"
+#~ msgstr "Vollständiger Seminarbericht"
+
+#~ msgid "Costs and participants only"
+#~ msgstr "Nur Kosten und Teilnehmende"
+
+#~ msgid "Mode"
+#~ msgstr "Modus"
+
+#~ msgid "Prepend V32"
+#~ msgstr "V32 Formblatt einfügen"
+
+#~ msgid "Please select a category."
+#~ msgstr "Bitte wähle eine Kategorie aus."
+
+#~ msgid "Alpinistic goals"
+#~ msgstr "Alpintechnische Ziele"
+
+#~ msgid "Pedagogic goals"
+#~ msgstr "Pädagogische Ziele"
+
+#~ msgid "Content and methods"
+#~ msgstr "Inhalte und Methoden"
+
+#~ msgid "Evaluation"
+#~ msgstr "Wertung"
+
+#~ msgid "Experiences and possible improvements"
+#~ msgstr "Erfahrungen und Verbesserungsvorschläge"
+
+#~ msgid ""
+#~ "Here you can generate a seminar report suitable for the LJP. A report\n"
+#~ "always contains a head page with the basic information on the seminar."
+#~ msgstr ""
+#~ "Hier kannst du einen Zuschussantrag für den Landesjugendplan (LJP) "
+#~ "erstellen. Ein solcher Antrag besteht immer aus zwei Teilen: Einem "
+#~ "Formblatt und einem Seminarbericht. Ein Bericht enthält immer einen Kopf "
+#~ "mit den Stammdaten des Seminars. Darüber hinaus muss der Seminarbericht "
+#~ "eine Teilnehemendenliste, eine Kostenübersicht und eine detaillierte "
+#~ "didaktische Planung enthalten. "
+
+#~ msgid ""
+#~ "Expenses with same short description are automatically summed up and "
+#~ "shown as one expense in the\n"
+#~ "expense overview."
+#~ msgstr ""
+#~ "In der Kostenübersicht werden Ausgaben mit der gleichen Kurzbeschreibung "
+#~ "automatisch aufsummiert und zu einer Ausgabe zusammengefasst."
+
+#~ msgid ""
+#~ "Full report: Include learning goals and a detailed, tabularized time "
+#~ "schedule. This requires\n"
+#~ "the seminar report section to be filled out."
+#~ msgstr ""
+#~ "Vollständiger Bericht: Stelle Lernziele und einen detaillierten, "
+#~ "tabellierten Zeitplan dar. Dies benötigt, dass der Seminarbericht in der "
+#~ "Ausfahrt ausgefüllt ist."
+
+#~ msgid ""
+#~ "Costs and participants only: Only show a list of participants and costs. "
+#~ "In this case you\n"
+#~ "have to add learning goals and a time schedule manually."
+#~ msgstr ""
+#~ "Nur Kosten und Teilnehmende: Zeige nur eine Liste von Teilnehmenden und "
+#~ "Kosten an. In diesem Fall musst du Lernziele und einen Zeitplan manuell "
+#~ "hinzufügen."
+
#, python-format
#~ msgid ""
#~ "This excursion has %(approved_count)s approved youth leaders, but you "
diff --git a/jdav_web/members/migrations/0035_ljp_application_rework.py b/jdav_web/members/migrations/0035_ljp_application_rework.py
new file mode 100644
index 0000000..7a6037c
--- /dev/null
+++ b/jdav_web/members/migrations/0035_ljp_application_rework.py
@@ -0,0 +1,68 @@
+# Generated by Django 4.0.1 on 2025-02-06 21:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('members', '0034_activitycategory_ljp_category'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='ljpproposal',
+ name='evaluation',
+ ),
+ migrations.RemoveField(
+ model_name='ljpproposal',
+ name='experiences',
+ ),
+ migrations.RemoveField(
+ model_name='ljpproposal',
+ name='goals_alpinistic',
+ ),
+ migrations.RemoveField(
+ model_name='ljpproposal',
+ name='goals_pedagogic',
+ ),
+ migrations.RemoveField(
+ model_name='ljpproposal',
+ name='methods',
+ ),
+ migrations.AddField(
+ model_name='ljpproposal',
+ name='goal',
+ field=models.IntegerField(choices=[(1, 'Qualification'), (2, 'Participation'), (3, 'Personality development'), (4, 'Environment')], default=1, help_text='Official learning goal according to LJP regulations.', verbose_name='Learning goal'),
+ ),
+ migrations.AddField(
+ model_name='ljpproposal',
+ name='goal_strategy',
+ field=models.TextField(blank=True, default='', help_text='How do you want to reach the learning goal? Has the goal been reached? If not, why not? If yes, what helped you to reach the goal?', verbose_name='Strategy'),
+ ),
+ migrations.AlterField(
+ model_name='ljpproposal',
+ name='title',
+ field=models.CharField(blank=True, default='', help_text='Official title of your seminar, this can differ from the informal title. Use e.g. sports climbing course instead of climbing weekend for fun.', max_length=30, verbose_name='Title'),
+ ),
+ migrations.AddField(
+ model_name='ljpproposal',
+ name='category',
+ field=models.IntegerField(choices=[(2, 'Educational programme'), (1, 'Staff training')], default=2, help_text='Type of seminar. Usually the correct choice is educational programme.', verbose_name='Category'),
+ ),
+ migrations.AddField(
+ model_name='ljpproposal',
+ name='not_bw_reason',
+ field=models.IntegerField(blank=True, choices=[(1, 'Course content'), (2, 'Available rooms'), (3, 'Close to the border'), (4, 'Economic reasons')], default=None, help_text='If the excursion takes place outside of Baden-Württemberg, please explain. Otherwise, leave this empty.', null=True, verbose_name='Explanation if excursion not in Baden-Württemberg'),
+ ),
+ migrations.AlterField(
+ model_name='ljpproposal',
+ name='title',
+ field=models.CharField(blank=True, default='', help_text='Official title of your seminar, this can differ from the informal title. Use e.g. sports climbing course instead of climbing weekend for fun.', max_length=100, verbose_name='Title'),
+ ),
+ migrations.AddField(
+ model_name='freizeit',
+ name='postcode',
+ field=models.CharField(default='', max_length=30, verbose_name='Postcode'),
+ ),
+ ]
diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py
index 4a9c504..0d1f99c 100644
--- a/jdav_web/members/models.py
+++ b/jdav_web/members/models.py
@@ -1058,6 +1058,7 @@ class Freizeit(CommonModel):
name = models.CharField(verbose_name=_('Activity'), default='',
max_length=50)
place = models.CharField(verbose_name=_('Place'), default='', max_length=50)
+ postcode = models.CharField(verbose_name=_('Postcode'), default='', max_length=30)
destination = models.CharField(verbose_name=_('Destination (optional)'),
default='', max_length=50, blank=True,
help_text=_('e.g. a peak'))
@@ -1146,6 +1147,13 @@ class Freizeit(CommonModel):
return full_days + extra_days
+ @property
+ def total_intervention_hours(self):
+ if hasattr(self, 'ljpproposal'):
+ return sum([i.duration for i in self.ljpproposal.intervention_set.all()])
+ else:
+ return 0
+
@property
def staff_count(self):
return self.jugendleiter.count()
@@ -1471,13 +1479,47 @@ class RegistrationPassword(models.Model):
class LJPProposal(CommonModel):
"""A proposal for LJP"""
- title = models.CharField(verbose_name=_('Title'), max_length=30)
-
- goals_alpinistic = models.TextField(verbose_name=_('Alpinistic goals'))
- goals_pedagogic = models.TextField(verbose_name=_('Pedagogic goals'))
- methods = models.TextField(verbose_name=_('Content and methods'))
- evaluation = models.TextField(verbose_name=_('Evaluation'))
- experiences = models.TextField(verbose_name=_('Experiences and possible improvements'))
+ title = models.CharField(verbose_name=_('Title'), max_length=100,
+ blank=True, default='',
+ help_text=_('Official title of your seminar, this can differ from the informal title. Use e.g. sports climbing course instead of climbing weekend for fun.'))
+
+ LJP_STAFF_TRAINING, LJP_EDUCATIONAL = 1, 2
+ LJP_CATEGORIES = [
+ (LJP_EDUCATIONAL, _('Educational programme')),
+ (LJP_STAFF_TRAINING, _('Staff training'))
+ ]
+ category = models.IntegerField(verbose_name=_('Category'),
+ choices=LJP_CATEGORIES,
+ default=2,
+ help_text=_('Type of seminar. Usually the correct choice is educational programme.'))
+ LJP_QUALIFICATION, LJP_PARTICIPATION, LJP_DEVELOPMENT, LJP_ENVIRONMENT = 1, 2, 3, 4
+ LJP_GOALS = [
+ (LJP_QUALIFICATION, _('Qualification')),
+ (LJP_PARTICIPATION, _('Participation')),
+ (LJP_DEVELOPMENT, _('Personality development')),
+ (LJP_ENVIRONMENT, _('Environment')),
+ ]
+ goal = models.IntegerField(verbose_name=_('Learning goal'),
+ choices=LJP_GOALS,
+ default=1,
+ help_text=_('Official learning goal according to LJP regulations.'))
+ goal_strategy = models.TextField(verbose_name=_('Strategy'),
+ help_text=_('How do you want to reach the learning goal? Has the goal been reached? If not, why not? If yes, what helped you to reach the goal?'),
+ blank=True, default='')
+
+ NOT_BW_CONTENT, NOT_BW_ROOMS, NOT_BW_CLOSE_BORDER, NOT_BW_ECONOMIC = 1, 2, 3, 4
+ NOT_BW_REASONS = [
+ (NOT_BW_CONTENT, _('Course content')),
+ (NOT_BW_ROOMS, _('Available rooms')),
+ (NOT_BW_CLOSE_BORDER, _('Close to the border')),
+ (NOT_BW_ECONOMIC, _('Economic reasons')),
+ ]
+ not_bw_reason = models.IntegerField(verbose_name=_('Explanation if excursion not in Baden-Württemberg'),
+ choices=NOT_BW_REASONS,
+ default=None,
+ blank=True,
+ null=True,
+ help_text=_('If the excursion takes place outside of Baden-Württemberg, please explain. Otherwise, leave this empty.'))
excursion = models.OneToOneField(Freizeit,
verbose_name=_('Excursion'),
diff --git a/jdav_web/members/pdf.py b/jdav_web/members/pdf.py
index ff43d94..fa12007 100644
--- a/jdav_web/members/pdf.py
+++ b/jdav_web/members/pdf.py
@@ -11,30 +11,20 @@ from django.template.loader import get_template
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect
from wsgiref.util import FileWrapper
-from contrib.media import media_path, media_dir, serve_media, ensure_media_dir
+from contrib.media import media_path, media_dir, serve_media, ensure_media_dir, find_template
from PIL import Image
-def find_template(template_name):
- for engine in template.engines.all():
- for loader in engine.engine.template_loaders:
- for origin in loader.get_template_sources(template_name):
- if os.path.exists(origin.name):
- return origin.name
- raise template.TemplateDoesNotExist(f"Could not find template: {template_name}")
-
-
def serve_pdf(filename_pdf):
return serve_media(filename_pdf, 'application/pdf')
-def render_tex(name, template_path, context, save_only=False):
+def generate_tex(name, template_path, context):
filename = name + "_" + datetime.today().strftime("%d_%m_%Y")
filename = filename.replace(' ', '_').replace('&', '').replace('/', '_')
# drop umlauts, accents etc.
filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
filename_tex = filename + '.tex'
- filename_pdf = filename + '.pdf'
tmpl = get_template(template_path)
res = tmpl.render(dict(context, creation_date=datetime.today().strftime('%d.%m.%Y')))
@@ -43,7 +33,27 @@ def render_tex(name, template_path, context, save_only=False):
with open(media_path(filename_tex), 'w', encoding='utf-8') as f:
f.write(res)
+ return filename
+
+
+def render_docx(name, template_path, context, save_only=False):
+ filename = generate_tex(name, template_path, context)
+ filename_tex = filename + '.tex'
+ filename_docx = filename + '.docx'
+ oldwd = os.getcwd()
+ os.chdir(media_dir())
+ subprocess.call(['pandoc', filename_tex, '-o', filename_docx])
+ time.sleep(1)
+ os.chdir(oldwd)
+ if save_only:
+ return filename_docx
+ return serve_media(filename_docx, 'application/docx')
+
+def render_tex(name, template_path, context, save_only=False):
+ filename = generate_tex(name, template_path, context)
+ filename_tex = filename + '.tex'
+ filename_pdf = filename + '.pdf'
# compile using pdflatex
oldwd = os.getcwd()
os.chdir(media_dir())
diff --git a/jdav_web/members/templates/admin/generate_seminar_report.html b/jdav_web/members/templates/admin/generate_seminar_report.html
index 6a52699..f52546e 100644
--- a/jdav_web/members/templates/admin/generate_seminar_report.html
+++ b/jdav_web/members/templates/admin/generate_seminar_report.html
@@ -23,41 +23,48 @@
{% endblock %}
{% block content %}
+{% trans 'LJP application for' %}: {{ memberlist.ljpproposal.title }} ({{ memberlist.name }})
-{% blocktrans %}Here you can generate a seminar report suitable for the LJP. A report
-always contains a head page with the basic information on the seminar.{% endblocktrans %}
+{% blocktrans %}For applying for contributions by the LJP, a seminar report is required. From the information
+that you entered in the excursion, you can automatically generate such a report here.{% endblocktrans %}
-{% blocktrans %}Expenses with same short description are automatically summed up and shown as one expense in the
-expense overview.{% endblocktrans %}
+{% blocktrans %}A seminar report consists of multiple components:{% endblocktrans %}
-
- -
-{% blocktrans %}Full report: Include learning goals and a detailed, tabularized time schedule. This requires
-the seminar report section to be filled out.{% endblocktrans %}
-
- -
-{% blocktrans %}Costs and participants only: Only show a list of participants and costs. In this case you
-have to add learning goals and a time schedule manually.{% endblocktrans %}
-
-
-
-{% blocktrans %}You may also choose to include the V32 attachment.{% endblocktrans %}
+
+
+
+|
+{% blocktrans %}An excel sheet containing the basic data of the seminar. This is also called the V-BK form.{% endblocktrans %}
+ |
+
+{% translate "Download" %}
+ |
+
+
+|
+{% blocktrans %}A pedagocial report on the strategy and outcome of the seminar with respect to achieving the
+learning goal.
+This also includes a detailed, tabularized time schedule and is produced as an editable Microsoft Word document.{% endblocktrans %}
+ |
+
+{% translate "Download" %}
+ |
+
+
+|
+{% blocktrans %}A cost and participants overview. This is not required for the actual application, but is provided for convience as a PDF document.{% endblocktrans %}
+ |
+
+{% translate "Download" %}
+ |
+
+
+
-
+
+{% translate "Back" %}
+
{% endblock %}
diff --git a/jdav_web/members/templates/members/LJP_VBK_3-1.xlsx b/jdav_web/members/templates/members/LJP_VBK_3-1.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..a02224a22f6f3c4de9f0de9f27b46851840656bf
GIT binary patch
literal 17651
zcmeHvWn&ykvaOhz(PCz1vX~hxw3wNhnPq{67RzF0W@cH;%*+b)-KMu(#th_zht7Zg&qV%FYoe
ztoU0WHprR^K0ZvSkq%r!`HL{DtXUs;%B-YzkGI+P!=Ws1(oEB|<^oj)R{Lm&H!g%u
zBvHmCa*Ti@CTRdgq;DM!Z#p9b!^Yfegr`yJN{6?Ei0NY!yJcYq&rxnqvL-7Yh2sRC
zy0)e$LCS9y3)6*QA)J5CFN}%!$@TuGbZyg4Y127huacy|#9fMBt-y7&k10tJC8ib#
z?Xk@06wc(&tO2U9GxC*|r4WsoRa{>(EfkdZ;nu{5>cUg?r@B|lYId!}u*orOKuqV_P2cDsh;$bRkT;tcqbGjFIrV-xT7~1^16#s|0>30h5
ziTCiBNP!t4uLq(1gDr8A-cwE}30O$C(~~$MSEdDOe|Mq^>YY5;$MAOHP|4%GB*GMi
z&;xJix$a))Fw`?9S*WS1XzKkie-rQ9i9+GD9p
zo4)5r4{wl&&0dLABTdq)<3OVp;DuoDrTS|Q$bMQkc&q^a@lpA#Jfy0TBm4NvWV-J{
za^VrYKp2CokHKqSHU^?xLZ)z42dIDnpgFd!ftAShrr
zD~3OK;%e()Xf&b+Mx6^&6w15a{b81P%m*2)F{ES%1~3G8Ji?6-J~r%tL&rb7OB>o(6rcO9t~9LPnH9U>Iw&hz2O-
zd?}^++1$n>rkc@y?u26%S$3-B=piE0ER*&0t;^du=-XiP-lV9ijcZ1Ogp#09>!6Ed
zQ#nKu!*H1}g#^(@e{3&qk9V6Ub&p0hi%=lJG^nEaeb5CqR%2`?x+E584%`A3gYG%4
zh26CHzWk^qnI``O=d~!iaLXWnAJ~v(D^6n;(a0!Ctw1yKu;5$S5l2Un(L=3G=3H0N
zbA_Zmpz8gAl~5S8yeI_v1|{PQj}7D#E{i5XG)7TtU3q={F-mCu$4AX|ngz%lJkLiIQGU2kdoKL!H3#|9f~
zqKp0
z)YF^pBssRWY0*y(a*RHv;nSj*NNhVo$llx<8b(*kKwgxBCvSm}#&-M%1=H$vT+l_VT5KFNTE#?sO=+e()K0FViV6i==q$&7kpJ3QyJ!~GF9+XH
zn*Iss1gU^&SB|1Z>X}e$RJ)>}50jBTo>dSPL
zYj^-Kb{2qMtUuM)(ahM`$&umr6YFobF)?u^0-(ns7qH%tIY008cQT^R$XJ8UMRO9f
zY>eHlA~h0dsD}5?cxu93j|$_k;7BGP4fo#%C$e;n#A7~}O;H!wBPoJJoJf5qFB0gu
z_@I`kF{skdT~1hJT6J6e_(;TKUfW3LMxFih_il`Be_s^CPLzmE}8VcD%;`Xm(KK+-2Th<9(RHN65Wd#z=ckoubAyQz5}
zMkz|M>h7mR?Ebt;X|1uewaQ@M^BByBx4u=dDW;{<3S_^vc=$7QVT)TtDP9$X;<5%6
z)LD+hz9n_GY6!yH2ZnQrgXzj4Mf1XYyLrSs2n-hAPJpx8mp*Ev)%uibE65#JFRrya
zsWK#StcPOl6(z}X+Q+FgAPt}ULG~MepqXZ<{1?LR;dUT%=kK4jZyJEaU5*bfD`V3Rp_=bQRx
zebN{U?vxYbHwI@kw(gl6oq?hCm2JLvxUQdX3r*p@^b3(k5)h8NJITmQ>NsH-U0NP0
z=+#F_BMwH5_;g{Vq|P#u-z6I!WkEfT9{lGtTSvQXw{BA?-vAcu@5;f&R3E