members/admin: adapt LJP application to new format #121

Merged
christian.merten merged 13 commits from cm-ljp-new into main 10 months ago

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

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

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

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

@ -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(
"<path:object_id>/download/ljp_vbk",
wrap(self.download_seminar_vbk),
name="%s_%s_download_ljp_vbk" % (self.opts.app_label, self.opts.model_name),
),
path("<path:object_id>/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("<path:object_id>/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:

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

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 "

@ -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'),
),
]

@ -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'),

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

@ -23,41 +23,48 @@
{% endblock %}
{% block content %}
<h2>{% trans 'LJP application for' %}: {{ memberlist.ljpproposal.title }} ({{ memberlist.name }})</h2>
<p>
{% 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 %}
</p>
<p>
{% 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 %}
</p>
<ul>
<li>
{% blocktrans %}Full report: Include learning goals and a detailed, tabularized time schedule. This requires
the seminar report section to be filled out.{% endblocktrans %}
</li>
<li>
{% 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 %}
</li>
</ul>
<br>
<p>{% blocktrans %}You may also choose to include the V32 attachment.{% endblocktrans %}</p>
<p>
<table>
<tr>
<td>
{% blocktrans %}An excel sheet containing the basic data of the seminar. This is also called the V-BK form.{% endblocktrans %}
</td>
<td>
<a href="{% url 'admin:members_freizeit_download_ljp_vbk' memberlist.pk %}" class="button">{% translate "Download" %}</a>
</td>
</tr>
<tr>
<td>
{% 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 %}
</td>
<td>
<a href="{% url 'admin:members_freizeit_download_ljp_report_docx' memberlist.pk %}" class="button">{% translate "Download" %}</a>
</td>
</tr>
<tr>
<td>
{% blocktrans %}A cost and participants overview. This is not required for the actual application, but is provided for convience as a PDF document.{% endblocktrans %}
</td>
<td>
<a href="{% url 'admin:members_freizeit_download_ljp_costs_participants' memberlist.pk %}" class="button">{% translate "Download" %}</a>
</td>
</tr>
</table>
</p>
<form action="" method="post">
{% csrf_token %}
<p>
<table>
{{ form }}
</table>
</p>
<br>
<input type="hidden" name="action" value="seminar_report">
<input type="hidden" name="seminar_report">
<input class="default" style="color: $default-link-color" type="submit" name="apply"
value="{% translate 'Generate' %}">
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
</form>
<p>
<a href="#" class="button cancel-link">{% translate "Back" %}</a>
</p>
{% endblock %}

@ -59,7 +59,7 @@
{{ settings.SEKTION_TOWN }} \\
Tel.: {{ settings.SEKTION_TELEPHONE }} \\
Fax: {{ settings.SEKTION_TELEFAX }} \\
{{ settings.SEKTION_CONTACT_MAIL }} \\
{{ settings.RESPONSIBLE_MAIL }} \\
\end{flushright}
\end{textblock*}

@ -0,0 +1,71 @@
{% load tex_extras %}
\documentclass[a4paper]{article}
\usepackage[utf8]{inputenc}
\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}
\titleformat{\section}
{\Large\slshape}{\thesection\;}
{0em}{}
\title{Seminarbericht}
\begin{document}
\maketitle
% DESCRIPTION TABLE
\begin{table}[H]
\begin{tabular}{ll}
\textbf{Sektion:} & {{ settings.SEKTION }} \\
\textbf{Titel der Maßnahme:} & {% if not memberlist.ljpproposal %}{{ memberlist.name|esc_all }}{% else %}{{ memberlist.ljpproposal.title }} {% endif %} \\
\textbf{Anzahl der durchgeführten Lehrgangstage:} & {{ memberlist.duration }} \\
\end{tabular}
\end{table}
\section{Bildungsziel}
\begin{table}[H]
\begin{tabular}{ccllllllllllll}
{% if memberlist.ljpproposal.goal == 1 %}x{% endif %}& 1 & \multicolumn{12}{l}{Ehrenamtliche qualifizieren und stärken} \\
{% if memberlist.ljpproposal.goal == 2 %}x{% endif %}& 2 & \multicolumn{12}{l}{Erleben von demokratischen Prozessen. Entwickeln und Stärken eines Demokratieverständnisses.} \\
{% if memberlist.ljpproposal.goal == 3 %}x{% endif %}& 3 & \multicolumn{12}{l}{Entwicklung der Persönlichkeit und Erweiterung des sozialen Handlungsrepertoires.} \\
{% if memberlist.ljpproposal.goal == 4 %}x{% endif %}& 4 & \multicolumn{12}{l}{Bewusstsein schaffen einer Verantwortung für Natur, Umwelt und zukünftige Generationen.} \\
\end{tabular}
\end{table}
\section{Zielverfolgung und -Erreichung}
{{ memberlist.ljpproposal.goal_strategy|esc_all }}
\section{Zeitlicher Ablauf}
\begin{table}[H]
\begin{tabular}{lllllll}
\toprule
\textbf{Datum} & \textbf{Uhrzeit} & \multicolumn{4}{l}{\textbf{Art der Aktion}} & \textbf{Dauer} \\
\midrule
{% for intervention in memberlist.ljpproposal.intervention_set.all %}
{{ intervention.date_start|date_short }}
& {{ intervention.date_start|time_short }}
& \multicolumn{4}{l}{ {{ intervention.activity|esc_all }} }
& {{ intervention.duration }} h \\
{% endfor %}
\bottomrule
& & \multicolumn{4}{l}{} & Summe: {{ memberlist.total_intervention_hours }} h \\
\end{tabular}
\end{table}
\end{document}

@ -18,3 +18,13 @@ def esc_all(val):
@register.filter
def datetime_short(date):
return date.strftime('%d.%m.%Y %H:%M')
@register.filter
def date_short(date):
return date.strftime('%d.%m.%y')
@register.filter
def time_short(date):
return date.strftime('%H:%M')

@ -17,7 +17,7 @@ from unittest import skip, mock
from .models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE,\
MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\
RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup, DIVERSE, MALE, FEMALE,\
Klettertreff, KlettertreffAttendee
Klettertreff, KlettertreffAttendee, LJPProposal
from .admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\
MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\
MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin
@ -671,6 +671,16 @@ class FreizeitAdminTestCase(AdminTestCase, PDFActionMixin):
self.bill = Bill.objects.create(statement=self.st, short_description='bla', explanation='bli',
amount=42.69, costs_covered=True, paid_by=fr,
proof=file)
self.ex2 = Freizeit.objects.create(name='Wild trip 2', kilometers_traveled=0,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1)
self.ljpproposal = LJPProposal.objects.create(title='My seminar',
category=LJPProposal.LJP_STAFF_TRAINING,
goal=LJPProposal.LJP_ENVIRONMENT,
goal_strategy='my strategy',
not_bw_reason=LJPProposal.NOT_BW_ROOMS,
excursion=self.ex2)
def test_changelist(self):
c = self._login('superuser')
@ -746,27 +756,50 @@ class FreizeitAdminTestCase(AdminTestCase, PDFActionMixin):
c = self._login('superuser')
url = reverse('admin:members_freizeit_action', args=(self.ex.pk,))
response = c.post(url, data={'seminar_report': ''})
response = c.post(url, data={'seminar_report': ''}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('You may also choose to include the V32 attachment.'))
self.assertContains(response,
_('This excursion does not have a LJP proposal. Please add one and try again.'))
url = reverse('admin:members_freizeit_action', args=(self.ex2.pk,))
response = c.post(url, data={'seminar_report': '', 'apply': ''})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Please select a mode.'))
self.assertContains(response, _('A seminar report consists of multiple components:'))
def test_invalid_download(self):
url = reverse('admin:members_freizeit_download_ljp_vbk', args=(self.ex.pk,))
c = self._login('standard')
response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("You are not allowed to view all members on excursion %(name)s.") % {'name': self.ex.name})
response = c.post(url, data={'seminar_report': '',
'apply': '',
'mode': 'full',
'prepend_v32': 'true'})
c = self._login('superuser')
response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('This excursion does not have a LJP proposal. Please add one and try again.'))
url = reverse('admin:members_freizeit_download_ljp_vbk', args=(123456789,))
response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Full mode is only available, if the seminar report section is filled out.'))
self.assertContains(response, _('Excursion not found.'))
response = c.post(url, data={'seminar_report': '',
'apply': '',
'mode': 'basic',
'prepend_v32': 'true'})
def test_download_seminar_vbk(self):
url = reverse('admin:members_freizeit_download_ljp_vbk', args=(self.ex2.pk,))
c = self._login('superuser')
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_download_seminar_report_docx(self):
url = reverse('admin:members_freizeit_download_ljp_report_docx', args=(self.ex2.pk,))
c = self._login('superuser')
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_download_seminar_report_costs_and_participants(self):
url = reverse('admin:members_freizeit_download_ljp_costs_participants', args=(self.ex2.pk,))
c = self._login('superuser')
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
print(mocked_fun.call_count)
@mock.patch('members.pdf.fill_pdf_form')
def test_sjr_application_post(self, mocked_fun):

@ -33,6 +33,7 @@ kombu==5.2.3
Markdown==3.4.3
MarkupSafe==3.0.2
mysqlclient==2.1.0
openpyxl==3.1.5
packaging==24.2
Pillow==9.0.0
prompt-toolkit==3.0.24

Loading…
Cancel
Save