From c8df5630b863d139aa13d8a0b1dbc0f477727fb3 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 00:11:39 +0200 Subject: [PATCH 01/18] refactor(latex): add base template and unify designs We add a base template for all latex documents to unify the desings and improve maintainability. Also the table definitions are simplified fixing the various width issues. --- .../members/crisis_intervention_list.tex | 77 +--------- .../templates/members/notelist_summary.tex | 39 ++---- .../members/templates/members/notes_list.tex | 51 ++----- .../templates/members/seminar_report.tex | 132 ++---------------- .../members/templates/members/tex_base.tex | 98 +++++++++++++ 5 files changed, 136 insertions(+), 261 deletions(-) create mode 100644 jdav_web/members/templates/members/tex_base.tex diff --git a/jdav_web/members/templates/members/crisis_intervention_list.tex b/jdav_web/members/templates/members/crisis_intervention_list.tex index dd15edf..8aaea17 100644 --- a/jdav_web/members/templates/members/crisis_intervention_list.tex +++ b/jdav_web/members/templates/members/crisis_intervention_list.tex @@ -1,74 +1,9 @@ +{% extends "members/tex_base.tex" %} {% load static common tex_extras %} -\documentclass[a4paper]{article} +{% block title %}Teilnehmer*innenliste Sektionsveranstaltung{% endblock %} -\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} - -\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 }} }\\ +{% block content %} % DESCRIPTION TABLE \begin{table}[H] @@ -89,7 +24,7 @@ \end{tabular} \end{table} -\begin{tabularx}{1\linewidth}{lSLSL} +\begin{tabularx}{.97\linewidth}{lXXXX} \toprule \# & \textbf{Name} & \textbf{Anschrift} & \textbf{Telefon} & \textbf{Notfallkontakte} \\ \midrule @@ -98,7 +33,7 @@ \endfoot {% for m in memberlist.membersonlist.all %} - {{ forloop.counter }} & + {{ forloop.counter }} & {{ m.member.name|esc_all }} & {{ m.member.address_multiline|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 \href{mailto:{{ settings.SEKTION_CRISIS_INTERVENTION_MAIL }}}{ {{ settings.SEKTION_CRISIS_INTERVENTION_MAIL }} } senden. -\end{document} +{% endblock content %} diff --git a/jdav_web/members/templates/members/notelist_summary.tex b/jdav_web/members/templates/members/notelist_summary.tex index 182132a..7a96ade 100644 --- a/jdav_web/members/templates/members/notelist_summary.tex +++ b/jdav_web/members/templates/members/notelist_summary.tex @@ -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} -% 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} +{% block content %} -\renewcommand{\arraystretch}{1.5} - -\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 @{}} +\begin{tabularx}{\textwidth}{l X} \toprule \textbf{Name} & \textbf{Kommentare} \\ \midrule @@ -36,7 +14,6 @@ {{ m.member.name|esc_all }} & {{ m.comments_tex|esc_all }} \\ {% endfor %} \bottomrule - \end{tabularx} -\end{table} +\end{tabularx} -\end{document} +{% endblock %} diff --git a/jdav_web/members/templates/members/notes_list.tex b/jdav_web/members/templates/members/notes_list.tex index e7ed249..39f8b0a 100644 --- a/jdav_web/members/templates/members/notes_list.tex +++ b/jdav_web/members/templates/members/notes_list.tex @@ -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} -% 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 }} }\\ +{% block content %} % DESCRIPTION \begin{table}[H] @@ -40,20 +17,21 @@ \end{tabular} \end{table} -\begin{tabularx}{1\linewidth}{ l l l Y} +\begin{tabularx}{.97\linewidth}{lXXX} \toprule \# & \textbf{Name} & \textbf{Fähigkeiten (max. 100)} & \textbf{Kommentare} \\ \midrule + \endhead + \bottomrule + \endfoot + {% for p in people %} {{ forloop.counter }} & {{ p.name|esc_all }} & {{ p.qualities|esc_all }} & {{ p.comments|esc_all }} \\ {% endfor %} - \bottomrule \end{tabularx} - -\noindent\large Fähigkeiten der Gruppe\\ -\begin{table}[H] - \begin{tabular*}{1\linewidth}{@{\extracolsep{\fill}}llll} +\noindent{\large Fähigkeiten der Gruppe}\\ +\begin{tabularx}{.97\linewidth}{Xlll} \toprule \textbf{Name} & \textbf{Durchschnitt} & \textbf{Minimum} & \textbf{Maximum} \\ \midrule @@ -61,9 +39,6 @@ {{ skill.name|esc_all }} & {{ skill.skill_avg|esc_all }} & {{ skill.skill_min|esc_all }} & {{ skill.skill_max|esc_all }} \\ {% endfor %} \bottomrule - \end{tabular*} -\end{table} - -\vspace{1cm} +\end{tabularx} -\end{document} +{% endblock %} diff --git a/jdav_web/members/templates/members/seminar_report.tex b/jdav_web/members/templates/members/seminar_report.tex index 8a6420e..1366d88 100644 --- a/jdav_web/members/templates/members/seminar_report.tex +++ b/jdav_web/members/templates/members/seminar_report.tex @@ -1,80 +1,9 @@ +{% extends "members/tex_base.tex" %} {% load static common tex_extras %} -\documentclass[a4paper]{article} +{% block title %}Seminarbericht{% endblock %} -\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=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 }} }\\ +{% block content %} % DESCRIPTION TABLE \begin{table}[H] @@ -89,54 +18,14 @@ \end{tabular} \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} -\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} - \hline +\begin{tabularx}{.97\linewidth}{lp{0.2\textwidth}Xlccc} + \toprule \# & \textbf{Name} & \textbf{Anschrift} & \textbf{Geburtsdatum} & \textbf{m} & \textbf{w} & \textbf{d} \\ - \hline + \midrule \endhead - \hline + \bottomrule \endfoot {% 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 }} @@ -150,7 +39,7 @@ \section{Kosten} -\begin{tabularx}{1\linewidth}{@{\extracolsep{\fill}}Lr} +\begin{tabularx}{.97\textwidth}{Xr} \toprule \textbf{Beschreibung} & \textbf{Betrag} \\ \midrule @@ -160,10 +49,11 @@ {% for bill in memberlist.statement.grouped_bills %} {{ bill.short_description|esc_all }} & {{ bill.amount }} € \\ {% endfor %} + \midrule + Gesamt & {{ memberlist.statement.total_theoretic }} € \\ \bottomrule - Gesamt & {{ memberlist.statement.total_theoretic }} € \\ \end{tabularx} {% endif %} -\end{document} +{% endblock %} diff --git a/jdav_web/members/templates/members/tex_base.tex b/jdav_web/members/templates/members/tex_base.tex new file mode 100644 index 0000000..ec9c852 --- /dev/null +++ b/jdav_web/members/templates/members/tex_base.tex @@ -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} From 2d694832762f7d240be74869814dc5210e65d34f Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 00:31:01 +0200 Subject: [PATCH 02/18] chore(style): remove trailing whitespace --- jdav_web/members/admin.py | 5 ++--- jdav_web/members/pdf.py | 10 ++++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 71ee88d..86c6137 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -959,7 +959,7 @@ class MemberOnListInline(CommonAdminInlineMixin, GenericTabularInline): } sortable_options = [] template = "admin/members/freizeit/memberonlistinline.html" - + def people_count(self, obj): if isinstance(obj, Freizeit): # Number of organizers who are also in the Memberlist @@ -967,7 +967,7 @@ class MemberOnListInline(CommonAdminInlineMixin, GenericTabularInline): # Total number of people in the Memberlist total_people = obj.head_count - + else: # fallback if no activity was found total_people = 0 organizer_count = 0 @@ -982,7 +982,6 @@ class MemberOnListInline(CommonAdminInlineMixin, GenericTabularInline): formset.organizer_count = self.people_count(obj)['organizer_count'] return formset - class MemberNoteListAdmin(admin.ModelAdmin): diff --git a/jdav_web/members/pdf.py b/jdav_web/members/pdf.py index 851d79c..c0291b1 100644 --- a/jdav_web/members/pdf.py +++ b/jdav_web/members/pdf.py @@ -75,28 +75,30 @@ def render_tex(name, template_path, context, save_only=False): def scale_pdf_page_to_a4(page): A4_WIDTH, A4_HEIGHT = 595, 842 - + page_width = page.mediabox.width page_height = page.mediabox.height scale_x = A4_WIDTH / page_width scale_y = A4_HEIGHT / page_height scale_factor = min(scale_x, scale_y) - + new_page = PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT) page.scale_by(scale_factor) x_offset = (A4_WIDTH - page.mediabox.width) / 2 y_offset = (A4_HEIGHT - page.mediabox.height) / 2 new_page.merge_translated_page(page, x_offset, y_offset) - + return new_page + def scale_pdf_to_a4(pdf): scaled_pdf = PdfWriter() for page in pdf.pages: scaled_pdf.add_page(scale_pdf_page_to_a4(page)) - + return scaled_pdf + def fill_pdf_form(name, template_path, fields, attachments=[], save_only=False): filename = normalize_filename(name) filename_pdf = filename + '.pdf' From ffef3c93c312e4f1cb765f389b4820c5dccd1bc0 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 01:32:54 +0200 Subject: [PATCH 03/18] fix(members/pdf): use excursion date instead of creation date --- jdav_web/members/admin.py | 23 +++++++++++++++++------ jdav_web/members/excel.py | 3 ++- jdav_web/members/pdf.py | 20 ++++++++++---------- jdav_web/utils.py | 8 +++++--- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 86c6137..ca21539 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -1032,7 +1032,8 @@ class MemberNoteListAdmin(admin.ModelAdmin): if not self.may_view_notelist(request, memberlist): return self.not_allowed_view(request, memberlist) 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') @@ -1125,7 +1126,9 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): if not self.may_view_excursion(request, memberlist): return self.not_allowed_view(request, memberlist) 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') def notes_list(self, request, memberlist): @@ -1133,7 +1136,9 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): return self.not_allowed_view(request, memberlist) people, skills = memberlist.skill_summary 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') @decorate_download @@ -1145,13 +1150,17 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): def download_seminar_report_docx(self, request, memberlist): title = memberlist.ljpproposal.title 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 def download_seminar_report_costs_and_participants(self, request, memberlist): title = memberlist.ljpproposal.title 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): if not self.may_view_excursion(request, memberlist): @@ -1198,7 +1207,9 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): context = memberlist.sjr_application_fields() 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)) diff --git a/jdav_web/members/excel.py b/jdav_web/members/excel.py index 9d8ac9e..20bbbc1 100644 --- a/jdav_web/members/excel.py +++ b/jdav_web/members/excel.py @@ -127,7 +127,8 @@ def generate_ljp_vbk(excursion): if hasattr(excursion, 'statement'): 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" workbook.save(media_path(filename)) return filename diff --git a/jdav_web/members/pdf.py b/jdav_web/members/pdf.py index c0291b1..fdd82f0 100644 --- a/jdav_web/members/pdf.py +++ b/jdav_web/members/pdf.py @@ -20,8 +20,8 @@ def serve_pdf(filename_pdf): return serve_media(filename_pdf, 'application/pdf') -def generate_tex(name, template_path, context): - filename = normalize_filename(name) +def generate_tex(name, template_path, context, date=None): + filename = normalize_filename(name, date=date) filename_tex = filename + '.tex' tmpl = get_template(template_path) @@ -34,8 +34,8 @@ def generate_tex(name, template_path, context): return filename -def render_docx(name, template_path, context, save_only=False): - filename = generate_tex(name, template_path, context) +def render_docx(name, template_path, context, date=None, save_only=False): + filename = generate_tex(name, template_path, context, date=date) filename_tex = filename + '.tex' filename_docx = filename + '.docx' oldwd = os.getcwd() @@ -48,8 +48,8 @@ def render_docx(name, template_path, context, save_only=False): return serve_media(filename_docx, 'application/docx') -def render_tex(name, template_path, context, save_only=False): - filename = generate_tex(name, template_path, context) +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_pdf = filename + '.pdf' # compile using pdflatex @@ -99,8 +99,8 @@ def scale_pdf_to_a4(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' path = find_template(template_path) @@ -140,13 +140,13 @@ def fill_pdf_form(name, template_path, fields, attachments=[], save_only=False): 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() for pdf in filenames: merger.append(media_path(pdf)) - filename = normalize_filename(name) + filename = normalize_filename(name, date=date) filename_pdf = filename + ".pdf" merger.write(media_path(filename_pdf)) merger.close() diff --git a/jdav_web/utils.py b/jdav_web/utils.py index b6704b9..32db40b 100644 --- a/jdav_web/utils.py +++ b/jdav_web/utils.py @@ -72,9 +72,11 @@ def normalize_name(raw, nospaces=True, noumlaut=True): return unicodedata.normalize('NFKD', raw).encode('ascii', 'ignore').decode('ascii') -def normalize_filename(filename, append_date=True): - if append_date: - filename = filename + "_" + datetime.today().strftime("%d_%m_%Y") +def normalize_filename(filename, append_date=True, date=None): + if append_date and not date: + date = datetime.today() + if date: + filename = filename + "_" + date.strftime("%d_%m_%Y") filename = filename.replace(' ', '_').replace('&', '').replace('/', '_') # drop umlauts, accents etc. return unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode() From 865df922227b9349bcdf9d364b8c9f38324ad753 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 02:40:14 +0200 Subject: [PATCH 04/18] feat(mailer/models): check global permissions for messages --- jdav_web/mailer/models.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index 69fe62d..e276772 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -10,6 +10,7 @@ from jdav_web.celery import app from django.core.validators import RegexValidator from django.conf import settings +from contrib.rules import has_global_perm from contrib.models import CommonModel from .rules import is_creator @@ -201,9 +202,9 @@ class Message(CommonModel): ("submit_mails", _("Can submit mails")), ) rules_permissions = { - "view_obj": is_creator, - "change_obj": is_creator, - "delete_obj": is_creator, + "view_obj": is_creator | has_global_perm('mailer.view_global_message'), + "change_obj": is_creator | has_global_perm('mailer.change_global_message'), + "delete_obj": is_creator | has_global_perm('mailer.delete_global_message'), } @@ -237,9 +238,9 @@ class Attachment(CommonModel): verbose_name = _('attachment') verbose_name_plural = _('attachments') rules_permissions = { - "add_obj": is_creator, - "view_obj": is_creator, - "change_obj": is_creator, - "delete_obj": is_creator, + "add_obj": is_creator | has_global_perm('mailer.view_global_message'), + "view_obj": is_creator | has_global_perm('mailer.view_global_message'), + "change_obj": is_creator | has_global_perm('mailer.change_global_message'), + "delete_obj": is_creator | has_global_perm('mailer.delete_global_message'), } From 5d728f1eeea3c81fb30db8ee667879abe36b0e7e Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 03:02:53 +0200 Subject: [PATCH 05/18] fix(settings/jet): correct sidebar permissions --- jdav_web/jdav_web/settings/components/jet.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jdav_web/jdav_web/settings/components/jet.py b/jdav_web/jdav_web/settings/components/jet.py index 470d63f..720fe7d 100644 --- a/jdav_web/jdav_web/settings/components/jet.py +++ b/jdav_web/jdav_web/settings/components/jet.py @@ -11,8 +11,8 @@ JET_SIDE_MENU_ITEMS = [ {'name': 'group', 'permissions': ['members.view_group']}, {'name': 'membernotelist', 'permissions': ['members.view_membernotelist']}, {'name': 'klettertreff', 'permissions': ['members.view_klettertreff']}, - {'name': 'activitycategory', 'permissions': ['members.view_group']}, - {'name': 'trainingcategory', 'permissions': ['members.view_group']}, + {'name': 'activitycategory', 'permissions': ['members.view_activitycategory']}, + {'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']}, ]}, {'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [ {'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']}, From 0535cce70f651b57a04d5193a00345c58eb5a3a9 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 14:14:31 +0200 Subject: [PATCH 06/18] feat(members/waitinglist): notify responsibles in case of rejections --- .../jdav_web/settings/components/texts.py | 18 ++++++++ jdav_web/members/admin.py | 10 ++++- .../members/locale/de/LC_MESSAGES/django.po | 20 ++++++++- .../0040_invitationtogroup_created_by.py | 19 ++++++++ jdav_web/members/models.py | 45 ++++++++++++++++++- jdav_web/members/views.py | 4 +- 6 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 jdav_web/members/migrations/0040_invitationtogroup_created_by.py diff --git a/jdav_web/jdav_web/settings/components/texts.py b/jdav_web/jdav_web/settings/components/texts.py index c094966..e5366b9 100644 --- a/jdav_web/jdav_web/settings/components/texts.py +++ b/jdav_web/jdav_web/settings/components/texts.py @@ -22,6 +22,24 @@ bestätige falls möglich. Zu der Registrierung kommst du hier: Viele Grüße 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_TIME_AVAILABLE_TEXT = get_text('group_time_available', default="""Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt.""") diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index ca21539..ea45863 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -712,7 +712,12 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): def invite_view(self, request, object_id): 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] id_list = [waiter.pk] else: @@ -756,7 +761,8 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): _("An error occurred while trying to invite said members. Please try again.")) return HttpResponseRedirect(request.get_full_path()) 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, _("Successfully invited %(name)s to %(group)s.") % {'name': w.name, 'group': w.invited_for_group.name}) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 1187b87..9b46b2b 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-03-08 16:16+0100\n" +"POT-Creation-Date: 2025-04-06 14:02+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -226,6 +226,10 @@ msgstr "Wartende auffordern den Wartelistenplatz zu bestätigen" msgid "Offer waiter a place in a group." 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 msgid "" "An error occurred while trying to invite said members. Please try again." @@ -763,6 +767,10 @@ msgstr "Einladungsdatum" msgid "Invitation rejected" msgstr "Einladung abgelehnt" +#: members/models.py +msgid "Created by" +msgstr "Erstellt von" + #: members/models.py msgid "Invitation to group" msgstr "Gruppeneinladung" @@ -787,6 +795,16 @@ msgstr "Ausstehend" msgid "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 msgid "Do you want to tell us something else?" msgstr "Möchtest du uns noch etwas mitteilen?" diff --git a/jdav_web/members/migrations/0040_invitationtogroup_created_by.py b/jdav_web/members/migrations/0040_invitationtogroup_created_by.py new file mode 100644 index 0000000..c017371 --- /dev/null +++ b/jdav_web/members/migrations/0040_invitationtogroup_created_by.py @@ -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'), + ), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index d18b06f..a1f179d 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -874,6 +874,11 @@ class InvitationToGroup(models.Model): date = models.DateField(default=timezone.now, verbose_name=_('Invitation date')) rejected = models.BooleanField(verbose_name=_('Invitation rejected'), default=False) 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: verbose_name = _('Invitation to group') @@ -891,6 +896,42 @@ class InvitationToGroup(models.Model): return _('Undecided') 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 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) + class MemberWaitingList(Person): """A participant on the waiting list""" @@ -1008,7 +1049,7 @@ class MemberWaitingList(Person): except InvitationToGroup.DoesNotExist: 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 and sends a personalized e-mail based on the passed template. @@ -1017,7 +1058,7 @@ class MemberWaitingList(Person): self.save() if not 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() self.send_mail(_("Invitation to trial group meeting"), text_template.format(name=self.prename, diff --git a/jdav_web/members/views.py b/jdav_web/members/views.py index 6280153..3cb22ea 100644 --- a/jdav_web/members/views.py +++ b/jdav_web/members/views.py @@ -470,10 +470,10 @@ def reject_invitation(request): except InvitationToGroup.DoesNotExist: return render_reject_invalid(request) if 'reject_invitation' in request.POST: - invitation.rejected = True - invitation.save() + invitation.reject() return render_reject_success(request, invitation) elif 'leave_waitinglist' in request.POST: + invitation.notify_left_waitinglist() invitation.waiter.unregister() return render_reject_success(request, invitation, leave_waitinglist=True) return render_reject_invalid(request) From d913c8049de3894087fee4ae4d1b24a67336ffbf Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 15:57:25 +0200 Subject: [PATCH 07/18] feat(members/waitinglist): add confirm link in invitation mail and more notifications --- .../jdav_web/settings/components/texts.py | 28 ++++++- jdav_web/mailer/mailutils.py | 4 + .../members/locale/de/LC_MESSAGES/django.po | 77 +++++++++++++++---- jdav_web/members/models.py | 45 +++++++++-- .../templates/members/confirm_invalid.html | 15 ++++ .../templates/members/confirm_invitation.html | 29 +++++++ .../templates/members/confirm_success.html | 23 ++++++ jdav_web/members/urls.py | 1 + jdav_web/members/views.py | 41 ++++++++++ 9 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 jdav_web/members/templates/members/confirm_invalid.html create mode 100644 jdav_web/members/templates/members/confirm_invitation.html create mode 100644 jdav_web/members/templates/members/confirm_success.html diff --git a/jdav_web/jdav_web/settings/components/texts.py b/jdav_web/jdav_web/settings/components/texts.py index e5366b9..69743dc 100644 --- a/jdav_web/jdav_web/settings/components/texts.py +++ b/jdav_web/jdav_web/settings/components/texts.py @@ -40,6 +40,28 @@ 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', default="""Die Gruppenstunde findet jeden {weekday} von {start_time} bis {end_time} Uhr statt.""") @@ -51,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. {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 Informationen und eine schriftliche Anmeldebestätigung von dir. Das kannst du alles über folgenden Link erledigen: diff --git a/jdav_web/mailer/mailutils.py b/jdav_web/mailer/mailutils.py index ba5cc67..b78cb1d 100644 --- a/jdav_web/mailer/mailutils.py +++ b/jdav_web/mailer/mailutils.py @@ -76,6 +76,10 @@ def get_invitation_reject_link(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): key = waiter.generate_wait_confirmation_key() return prepend_base_url("/members/waitinglist/confirm?key={}".format(key)) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 9b46b2b..e2d3d42 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-04-06 14:02+0200\n" +"POT-Creation-Date: 2025-04-06 15:38+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -805,6 +805,15 @@ msgstr "%(waiter)s hat die Warteliste verlassen" 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 msgid "Do you want to tell us something else?" msgstr "Möchtest du uns noch etwas mitteilen?" @@ -1655,6 +1664,60 @@ msgstr "Fähigkeitsniveau" msgid "Save and confirm registration" 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_failed.html #: members/templates/members/echo_password.html @@ -1942,10 +2005,6 @@ msgstr "" msgid "Reject invitation" 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 #, python-format msgid "" @@ -2240,14 +2299,6 @@ msgstr "Ungültige Notfallkontakte" #~ msgid "Good conduct certificate presentation needed" #~ 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" #~ msgstr "Ja, Einladung ablehnen." diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index a1f179d..7e6eecc 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -18,7 +18,8 @@ from utils import RestrictedFileField, normalize_name import os from mailer.mailutils import send as send_mail, get_mail_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.conf import settings from django.core.validators import MinValueValidator @@ -109,6 +110,14 @@ class Group(models.Model): # return if the group has all relevant time slot information filled 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): """The text template used to invite waiters to this group. This contains placeholders for the name of the waiter and personalized links.""" @@ -117,9 +126,7 @@ class Group(models.Model): else: group_link = '' if self.has_time_info(): - group_time = 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')) + group_time = self.get_time_info() else: group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email) return settings.INVITE_TEXT.format(group_time=group_time, @@ -912,6 +919,21 @@ class InvitationToGroup(models.Model): 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, @@ -932,6 +954,18 @@ class InvitationToGroup(models.Model): 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): """A participant on the waiting list""" @@ -1063,7 +1097,8 @@ class MemberWaitingList(Person): self.send_mail(_("Invitation to trial group meeting"), text_template.format(name=self.prename, 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) def unregister(self): diff --git a/jdav_web/members/templates/members/confirm_invalid.html b/jdav_web/members/templates/members/confirm_invalid.html new file mode 100644 index 0000000..8448e06 --- /dev/null +++ b/jdav_web/members/templates/members/confirm_invalid.html @@ -0,0 +1,15 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Confirm invitation" %} +{% endblock %} + +{% block content %} + +

{% trans "Confirm invitation" %}

+ +

{% trans "This invitation is invalid or expired." %}

+ +{% endblock %} diff --git a/jdav_web/members/templates/members/confirm_invitation.html b/jdav_web/members/templates/members/confirm_invitation.html new file mode 100644 index 0000000..f2af2c6 --- /dev/null +++ b/jdav_web/members/templates/members/confirm_invitation.html @@ -0,0 +1,29 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Confirm trial group meeting invitation" %} +{% endblock %} + +{% block content %} + +

{% trans "Confirm trial group meeting invitation" %}

+ +

+{% blocktrans %}You were invited to a trial group meeting of the group {{ groupname }}.{% endblocktrans %} +{{ timeinfo }} +

+ +

+{% 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 %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/jdav_web/members/templates/members/confirm_success.html b/jdav_web/members/templates/members/confirm_success.html new file mode 100644 index 0000000..213355a --- /dev/null +++ b/jdav_web/members/templates/members/confirm_success.html @@ -0,0 +1,23 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Invitation confirmed" %} +{% endblock %} + +{% block content %} + +

{% trans "Invitation confirmed" %}

+ +

+{% blocktrans %}You successfully confirmed the invitation to the trial group meeting of the group {{ groupname }}.{% endblocktrans %} +{{ timeinfo }} +

+

+{% 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 %} +{{ contact_email }}. +

+ +{% endblock %} diff --git a/jdav_web/members/urls.py b/jdav_web/members/urls.py index 7390159..1fdedfb 100644 --- a/jdav_web/members/urls.py +++ b/jdav_web/members/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ 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/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'^mail/confirm', views.confirm_mail , name='confirm_mail'), ] diff --git a/jdav_web/members/views.py b/jdav_web/members/views.py index 3cb22ea..7f9abee 100644 --- a/jdav_web/members/views.py +++ b/jdav_web/members/views.py @@ -479,6 +479,47 @@ def reject_invitation(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): if request.method == 'GET' and 'key' in request.GET: key = request.GET['key'] From 33ab4e481d2dde349ff6fd2907a4b89ebe47a9ee Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 6 Apr 2025 18:49:19 +0200 Subject: [PATCH 08/18] fix(finance/admin): check existence and permission in finance admin views --- jdav_web/finance/admin.py | 35 ++++++++++++++----- .../finance/locale/de/LC_MESSAGES/django.po | 10 +++++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 0f5237d..e24b5e9 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -40,6 +40,23 @@ class BillOnStatementInline(CommonAdminInlineMixin, admin.TabularInline): 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) class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): fields = ['short_description', 'explanation', 'excursion', 'submitted'] @@ -77,8 +94,8 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): ] return custom_urls + urls - def submit_view(self, request, object_id): - statement = Statement.objects.get(pk=object_id) + @decorate_statement_view(Statement) + def submit_view(self, request, statement): if statement.submitted: messages.error(request, _("%(name)s is already submitted.") % {'name': str(statement)}) @@ -89,7 +106,7 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): messages.success(request, _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)}) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) - + if statement.excursion: memberlist = statement.excursion context = dict(self.admin_site.each_context(request), @@ -183,8 +200,8 @@ class StatementSubmittedAdmin(admin.ModelAdmin): ] return custom_urls + urls - def overview_view(self, request, object_id): - statement = StatementSubmitted.objects.get(pk=object_id) + @decorate_statement_view(StatementSubmitted) + def overview_view(self, request, statement): if not statement.submitted: messages.error(request, _("%(name)s is not yet submitted.") % {'name': str(statement)}) @@ -259,8 +276,8 @@ class StatementSubmittedAdmin(admin.ModelAdmin): return render(request, 'admin/overview_submitted_statement.html', context=context) - def reduce_transactions_view(self, request, object_id): - statement = StatementSubmitted.objects.get(pk=object_id) + @decorate_statement_view(StatementSubmitted) + def reduce_transactions_view(self, request, statement): statement.reduce_transactions() messages.success(request, _("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) @@ -307,8 +324,8 @@ class StatementConfirmedAdmin(admin.ModelAdmin): ] return custom_urls + urls - def unconfirm_view(self, request, object_id): - statement = StatementConfirmed.objects.get(pk=object_id) + @decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') + def unconfirm_view(self, request, statement): if not statement.confirmed: messages.error(request, _("%(name)s is not yet confirmed.") % {'name': str(statement)}) diff --git a/jdav_web/finance/locale/de/LC_MESSAGES/django.po b/jdav_web/finance/locale/de/LC_MESSAGES/django.po index b4ba049..2a6f1a6 100644 --- a/jdav_web/finance/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/finance/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-01 21:11+0100\n" +"POT-Creation-Date: 2025-04-06 18:46+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +18,14 @@ msgstr "" "Content-Transfer-Encoding: 8bit\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 #, python-format msgid "%(name)s is already submitted." From f213e1177209b64a9149c6e7a405a08873b312f0 Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Mon, 7 Apr 2025 00:29:35 +0200 Subject: [PATCH 09/18] feat(finance): creation of receipts for confirmed statements and payment of LJP contributions (#150) Confirmed statements now come with automatically generated PDF receipts used for documenting all issued payments. This PR also adds generation of transactions for LJP contributions and their validation. closes #92 Co-authored-by: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Reviewed-on: https://git.jdav-hd.merten.dev/digitales/kompass/pulls/150 Reviewed-by: Christian Merten Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/finance/admin.py | 42 +++++- .../finance/locale/de/LC_MESSAGES/django.po | 72 ++++++++- .../migrations/0009_statement_ljp_to.py | 20 +++ jdav_web/finance/models.py | 57 +++++++- .../admin/overview_submitted_statement.html | 58 +++++++- .../templates/finance/statement_summary.tex | 137 ++++++++++++++++++ jdav_web/jdav_web/settings/local.py | 1 + jdav_web/locale/de/LC_MESSAGES/django.po | 6 +- jdav_web/members/admin.py | 14 +- jdav_web/members/excel.py | 2 +- .../members/locale/de/LC_MESSAGES/django.po | 41 +++++- jdav_web/members/models.py | 40 ++++- jdav_web/members/pdf.py | 56 ++++--- .../admin/freizeit_finance_overview.html | 29 +++- .../templates/members/seminar_report_docx.tex | 2 +- .../change_form_object_tools.html | 5 +- 16 files changed, 541 insertions(+), 41 deletions(-) create mode 100644 jdav_web/finance/migrations/0009_statement_ljp_to.py create mode 100644 jdav_web/finance/templates/finance/statement_summary.tex diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index e24b5e9..f9a9e2a 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin, messages +from django.utils.safestring import mark_safe from django import forms from django.forms import Textarea, ClearableFileInput from django.http import HttpResponse, HttpResponseRedirect @@ -13,6 +14,7 @@ from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin from utils import get_member, RestrictedFileField from rules.contrib.admin import ObjectPermissionsModelAdmin +from members.pdf import render_tex_with_attachments from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ StatementUnSubmitted, BillOnStatementProxy @@ -115,7 +117,7 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): memberlist=memberlist, object=memberlist, participant_count=memberlist.participant_count, - ljp_contributions=memberlist.potential_ljp_contributions, + ljp_contributions=memberlist.payable_ljp_contributions, total_relative_costs=memberlist.total_relative_costs, **memberlist.statement.template_context()) return render(request, 'admin/freizeit_finance_overview.html', context=context) @@ -129,12 +131,23 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): class TransactionOnSubmittedStatementInline(admin.TabularInline): model = Transaction - fields = ['amount', 'member', 'reference', 'ledger'] + fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger'] formfield_overrides = { TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} } + readonly_fields = ['text_length_warning'] extra = 0 + def text_length_warning(self, obj): + """Display reference length, warn if exceeds 140 characters.""" + len_reference = len(obj.reference) + len_string = f"{len_reference}/140" + if len_reference > 140: + return mark_safe(f'{len_string}') + + return len_string + text_length_warning.short_description = _("Length") + class BillOnSubmittedStatementInline(BillOnStatementInline): model = BillOnStatementProxy @@ -217,6 +230,9 @@ class StatementSubmittedAdmin(admin.ModelAdmin): messages.success(request, _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") % {'name': str(statement)}) + download_link = reverse('admin:finance_statementconfirmed_summary', args=(statement.pk,)) + messages.success(request, + mark_safe(_("You can download a receipt.") % {'link': download_link})) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) if "confirm" in request.POST: res = statement.validity @@ -271,6 +287,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin): title=_('View submitted statement'), opts=self.opts, statement=statement, + settings=settings, transaction_issues=statement.transaction_issues, **statement.template_context()) @@ -321,6 +338,11 @@ class StatementConfirmedAdmin(admin.ModelAdmin): wrap(self.unconfirm_view), name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name), ), + path( + "/summary/", + wrap(self.statement_summary_view), + name="%s_%s_summary" % (self.opts.app_label, self.opts.model_name), + ), ] return custom_urls + urls @@ -348,6 +370,22 @@ class StatementConfirmedAdmin(admin.ModelAdmin): return render(request, 'admin/unconfirm_statement.html', context=context) + @decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') + def statement_summary_view(self, request, statement): + if not statement.confirmed: + messages.error(request, + _("%(name)s is not yet confirmed.") % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + excursion = statement.excursion + context = dict(statement=statement.template_context(), excursion=excursion, settings=settings) + + pdf_filename = f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else f"Abrechnungsbeleg" + attachments = [bill.proof.path for bill in statement.bills_covered if bill.proof] + return render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex', context, attachments) + + statement_summary_view.short_description = _('Download summary') + + @admin.register(Transaction) class TransactionAdmin(admin.ModelAdmin): """The transaction admin site. This is only used to display transactions. All editing diff --git a/jdav_web/finance/locale/de/LC_MESSAGES/django.po b/jdav_web/finance/locale/de/LC_MESSAGES/django.po index 2a6f1a6..5cadb5a 100644 --- a/jdav_web/finance/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/finance/locale/de/LC_MESSAGES/django.po @@ -48,6 +48,10 @@ msgstr "Kostenübersicht" msgid "Submit statement" msgstr "Rechnung einreichen" +#: finance/admin.py +msgid "Length" +msgstr "Länge" + #: finance/admin.py #, python-format msgid "%(name)s is not yet submitted." @@ -69,6 +73,13 @@ msgstr "" "Erfolgreich %(name)s abgewickelt. Ich hoffe du hast die zugehörigen " "Überweisungen ausgeführt, ich werde dich nicht nochmal erinnern." +#: finance/admin.py +#, python-format +msgid "You can download a receipt." +msgstr "" +"Hier kannst du den Abrechnungsbeleg herunterladen." + #: finance/admin.py msgid "Statement confirmed" msgstr "Abrechnung abgewickelt" @@ -160,6 +171,10 @@ msgstr "" msgid "Unconfirm statement" msgstr "Bestätigung zurücknehmen" +#: finance/admin.py +msgid "Download summary" +msgstr "Beleg herunterladen" + #: finance/apps.py msgid "Finance" msgstr "Finanzen" @@ -211,6 +226,18 @@ msgstr "" "Die Person, die die Übernachtungs- und Fahrtkostenzuschüsse erhalten soll. " "Dies ist in der Regel die Person, die sie bezahlt hat." +#: finance/models.py +msgid "Pay ljp contributions to" +msgstr "LJP-Zuschüsse auszahlen an" + +#: finance/models.py +msgid "" +"The person that should receive the ljp contributions for the participants. " +"Should be only selected if an ljp request was submitted." +msgstr "" +"Die Person, die die LJP-Zuschüsse für die Teilnehmenden erhalten soll. Nur " +"auswählen, wenn ein LJP-Antrag abgegeben wird." + #: finance/models.py msgid "Price per night" msgstr "Preis pro Nacht" @@ -270,7 +297,12 @@ msgstr "Aufwandsentschädigung für %(excu)s" msgid "Night and travel costs for %(excu)s" msgstr "Übernachtungs- und Fahrtkosten für %(excu)s" -#: finance/models.py finance/templates/admin/overview_submitted_statement.html +#: finance/models.py +#, python-format +msgid "LJP-Contribution %(excu)s" +msgstr "LJP-Zuschuss %(excu)s" + +#: finance/models.py msgid "Total" msgstr "Gesamtbetrag" @@ -509,6 +541,44 @@ msgstr "" "Keine Empfänger*innen für Sektionszuschüsse angegeben. Es werden daher keine " "Sektionszuschüsse ausbezahlt." +#: finance/templates/admin/overview_submitted_statement.html +#, python-format +msgid "" +" The youth leaders have documented interventions worth of " +"%(total_seminar_days)s seminar \n" +"days for %(participant_count)s eligible participants. Taking into account " +"the maximum contribution quota \n" +"of 90%% and possible taxes (%(ljp_tax)s%%), this results in a total of " +"%(paid_ljp_contributions)s€. \n" +"Once their proposal was approved, the ljp contributions of should be paid to:" +msgstr "" +"Jugendleiter*innen haben Lerneinheiten für insgesamt %(total_seminar_days)s " +"Seminartage und für %(participant_count)s Teilnehmende dokumentiert. Unter " +"Einbezug der maximalen Förderquote von 90%% und möglichen Steuern " +"(%(ljp_tax)s%%), ergibt sich ein auszuzahlender Betrag von " +"%(paid_ljp_contributions)s€. Sobald der LJP-Antrag geprüft ist, können LJP-" +"Zuschüsse ausbezahlt werden an:" + +#: finance/templates/admin/overview_submitted_statement.html +msgid "Summary" +msgstr "Zusammenfassung" + +#: finance/templates/admin/overview_submitted_statement.html +msgid "Covered bills" +msgstr "Übernommene Ausgaben" + +#: finance/templates/admin/overview_submitted_statement.html +msgid "Allowance" +msgstr "Aufwandsentschädigung" + +#: finance/templates/admin/overview_submitted_statement.html +msgid "Contributions by the association" +msgstr "Sektionszuschüsse" + +#: finance/templates/admin/overview_submitted_statement.html +msgid "ljp contributions" +msgstr "LJP-Zuschüsse" + #: finance/templates/admin/overview_submitted_statement.html #, python-format msgid "This results in a total amount of %(total)s€" diff --git a/jdav_web/finance/migrations/0009_statement_ljp_to.py b/jdav_web/finance/migrations/0009_statement_ljp_to.py new file mode 100644 index 0000000..fc13323 --- /dev/null +++ b/jdav_web/finance/migrations/0009_statement_ljp_to.py @@ -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'), + ), + ] diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index 5fb1aa9..5765aef 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -70,6 +70,13 @@ class Statement(CommonModel): related_name='receives_subsidy_for_statements', help_text=_('The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.')) + ljp_to = models.ForeignKey(Member, verbose_name=_('Pay ljp contributions to'), + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='receives_ljp_for_statements', + help_text=_('The person that should receive the ljp contributions for the participants. Should be only selected if an ljp request was submitted.')) + night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5) submitted = models.BooleanField(verbose_name=_('Submitted'), default=False) @@ -128,6 +135,8 @@ class Statement(CommonModel): needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()]) if self.subsidy_to: needed_paiments.append((self.subsidy_to, self.total_subsidies)) + if self.ljp_to: + needed_paiments.append((self.ljp_to, self.paid_ljp_contributions)) needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk) target = dict(map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0]))) @@ -242,6 +251,10 @@ class Statement(CommonModel): ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name} Transaction(statement=self, member=self.subsidy_to, amount=self.total_subsidies, confirmed=False, reference=ref).save() + if self.ljp_to: + ref = _("LJP-Contribution %(excu)s") % {'excu': self.excursion.name} + Transaction(statement=self, member=self.ljp_to, amount=self.paid_ljp_contributions, confirmed=False, reference=ref).save() + return True def reduce_transactions(self): @@ -261,7 +274,7 @@ class Statement(CommonModel): continue new_amount = sum((trans.amount for trans in grp)) - new_ref = "\n".join((trans.reference for trans in grp)) + new_ref = ", ".join((f"{trans.reference} EUR{trans.amount: .2f}" for trans in grp)) Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref, ledger=ledger).save() for trans in grp: @@ -269,12 +282,27 @@ class Statement(CommonModel): @property def total_bills(self): - return sum([bill.amount for bill in self.bill_set.all() if bill.costs_covered]) + return sum([bill.amount for bill in self.bills_covered]) + + @property + def bills_covered(self): + """Returns the bills that are marked for reimbursement by the finance officer""" + return [bill for bill in self.bill_set.all() if bill.costs_covered] + @property + def bills_without_proof(self): + """Returns the bills that lack a proof file""" + return [bill for bill in self.bill_set.all() if not bill.proof] + @property def total_bills_theoretic(self): return sum([bill.amount for bill in self.bill_set.all()]) + @property + def total_bills_not_covered(self): + """Returns the sum of bills that are not marked for reimbursement by the finance officer""" + return sum([bill.amount for bill in self.bill_set.all()]) - self.total_bills + @property def euro_per_km(self): if self.excursion is None: @@ -380,10 +408,23 @@ class Statement(CommonModel): return 0 else: return self.excursion.approved_staff_count + + @property + def paid_ljp_contributions(self): + if hasattr(self.excursion, 'ljpproposal') and self.ljp_to: + return cvt_to_decimal( + min( + (1-settings.LJP_TAX) * settings.LJP_CONTRIBUTION_PER_DAY * self.excursion.ljp_participant_count * self.excursion.ljp_duration, + (1-settings.LJP_TAX) * 0.9 * (float(self.total_bills_not_covered) + float(self.total_staff) ), + float(self.total_bills_not_covered) + ) + ) + else: + return 0 @property def total(self): - return self.total_bills + self.total_staff + return self.total_bills + self.total_staff + self.paid_ljp_contributions @property def total_theoretic(self): @@ -403,6 +444,7 @@ class Statement(CommonModel): context = { 'total_bills': self.total_bills, 'total_bills_theoretic': self.total_bills_theoretic, + 'bills_covered': self.bills_covered, 'total': self.total, } if self.excursion: @@ -421,9 +463,18 @@ class Statement(CommonModel): 'transportation_per_yl': self.transportation_per_yl, 'total_per_yl': self.total_per_yl, 'total_staff': self.total_staff, + 'total_allowance': self.total_allowance, 'theoretical_total_staff': self.theoretical_total_staff, 'real_staff_count': self.real_staff_count, 'total_subsidies': self.total_subsidies, + 'total_allowance': self.total_allowance, + 'subsidy_to': self.subsidy_to, + 'allowance_to': self.allowance_to, + 'paid_ljp_contributions': self.paid_ljp_contributions, + 'ljp_to': self.ljp_to, + 'participant_count': self.excursion.participant_count, + 'total_seminar_days': self.excursion.total_seminar_days, + 'ljp_tax': settings.LJP_TAX * 100, } return dict(context, **excursion_context) else: diff --git a/jdav_web/finance/templates/admin/overview_submitted_statement.html b/jdav_web/finance/templates/admin/overview_submitted_statement.html index e6196de..0d97736 100644 --- a/jdav_web/finance/templates/admin/overview_submitted_statement.html +++ b/jdav_web/finance/templates/admin/overview_submitted_statement.html @@ -113,9 +113,65 @@

{% blocktrans %}No receivers of the subsidies were provided. Subsidies will not be used.{% endblocktrans %}

{% endif %} +{% if statement.ljp_to %} +

+{% 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 %} + + + + + + + +
+ {% trans "IBAN valid" %}
{{ statement.ljp_to.name }}{{ statement.ljp_to.iban_valid|render_bool }}
+

{% endif %} -

{% trans "Total" %}

+ +{% endif %} + + +

{% trans "Summary" %}

+ + + + + + + + + + + + + + + + + + + +
+ {% trans "Covered bills" %} + + {{ total_bills }}€ +
+ {% trans "Allowance" %} + + {{ total_allowance }}€ +
+ {% trans "Contributions by the association" %} + + {{ total_subsidies }}€ +
+ {% trans "ljp contributions" %} + + {{ paid_ljp_contributions }}€ +

{% blocktrans %}This results in a total amount of {{ total }}€{% endblocktrans %} diff --git a/jdav_web/finance/templates/finance/statement_summary.tex b/jdav_web/finance/templates/finance/statement_summary.tex new file mode 100644 index 0000000..ee37f02 --- /dev/null +++ b/jdav_web/finance/templates/finance/statement_summary.tex @@ -0,0 +1,137 @@ +{% extends "members/tex_base.tex" %} +{% load static common tex_extras %} + +{% block title %}Abrechnungs- und Zuschussbeleg\\[2mm]Sektionsveranstaltung{% endblock %} + +{% block content %} + +{% if excursion %} +\noindent\textbf{\large Ausfahrt} + +% DESCRIPTION TABLE +\begin{table}[H] + \begin{tabular}{ll} + Aktivität: & {{ excursion.name|esc_all }} \\ + Ordnungsnummer & {{ excursion.code|esc_all }} \\ + Ort / Stützpunkt: & {{ excursion.place|esc_all }} \\ + Zeitraum: & {{ excursion.duration|esc_all}} Tage ({{ excursion.time_period_str|esc_all }}) \\ + Teilnehmer*innen: & {{ excursion.participant_count }} der Gruppe(n) {{ excursion.groups_str|esc_all }} \\ + Betreuer*innen: & {{excursion.staff_count|esc_all }} ({{ excursion.staff_str|esc_all }}) \\ + Art der Tour: & {% checked_if_true 'Gemeinschaftstour' excursion.get_tour_type %} + {% checked_if_true 'Führungstour' excursion.get_tour_type %} + {% checked_if_true 'Ausbildung' excursion.get_tour_type %} \\ + Anreise: & {% checked_if_true 'ÖPNV' excursion.get_tour_approach %} + {% checked_if_true 'Muskelkraft' excursion.get_tour_approach %} + {% checked_if_true 'Fahrgemeinschaften' excursion.get_tour_approach %} + \end{tabular} +\end{table} + +\noindent\textbf{\large Zuschüsse und Aufwandsentschädigung} +{% if excursion.approved_staff_count > 0 %} + +\noindent Gemäß Beschluss des Jugendausschusses gelten folgende Sätze für Zuschüsse pro genehmigter Jugendleiter*in: + +\begin{table}[H] + \centering + \begin{tabularx}{.97\textwidth}{Xllr} + \toprule + \textbf{Posten} & \textbf{Einzelsatz} & \textbf{Anzahl} & \textbf{Gesamtbetrag pro JL} \\ + \midrule + Zuschuss Übernachtung & {{ statement.price_per_night }} € / Nacht & {{ statement.nights }} Nächte & {{ statement.nights_per_yl }} € \\ + Zuschuss Anreise & {{statement.euro_per_km}} € / km ({{ statement.means_of_transport }}) & {{ statement.kilometers_traveled }} km & {{ statement.transportation_per_yl }} € \\ + Aufwandsentschädigung & {{ statement.allowance_per_day }},00 € / Tag & {{ statement.duration }} Tage & {{ statement.allowance_per_yl }} € \\ + \midrule + \textbf{Summe}& & & \textbf{ {{ statement.total_per_yl }} }€\\ + \bottomrule + \end{tabularx} +\end{table} + +\noindent Gemäß JDAV-Betreuungsschlüssel können bei {{ excursion.participant_count }} Teilnehmer*innen +bis zu {{ excursion.approved_staff_count }} Jugendleiter*innen {% if excursion.approved_extra_youth_leader_count %} +(davon {{ excursion.approved_extra_youth_leader_count }} durch das Jugendreferat zusätzlich genehmigt){% endif %} bezuschusst werden. +Zuschüsse und Aufwandsentschädigung werden wie folgt abgerufen: +\begin{itemize} + + {% if statement.allowances_paid > 0 %} + + \item Eine Aufwandsentschädigung von {{ statement.allowance_per_yl }} € pro Jugendleiter*in wird überwiesen an: + {% for m in statement.allowance_to.all %}{% if forloop.counter > 1 %}, {% endif %}{{ m.name }}{% endfor %} + {% else %} + \item Keiner*r der Jugendleiter*innen nimmt eine Aufwandsentschädigung in Anspruch. + {% endif %} + + {% if statement.subsidy_to %} + \item Der Zuschuss zu Übernachtung und Anreise für alle Jugendleiter*innen in Höhe von {{ statement.total_subsidies }} € wird überwiesen an: + {{ statement.subsidy_to.name }} + + {% else %} + \item Zuschüsse zu Übernachtung und Anreise werden nicht in Anspruch genommen. + {% endif %} + +\end{itemize} +{% else %} +\noindent Für die vorliegende Ausfahrt sind keine Jugendleiter*innen anspruchsberechtigt für Zuschüsse oder Aufwandsentschädigung. + +{% endif %} + +{% if statement.ljp_to %} +\noindent\textbf{LJP-Zuschüsse} + +\noindent Der LJP-Zuschuss für die Teilnehmenden in Höhe von {{ statement.paid_ljp_contributions|esc_all }} € wird überwiesen an: +{{ statement.ljp_to.name|esc_all }} Dieser Zuschuss wird aus Landesmitteln gewährt und ist daher +in der Ausgabenübersicht gesondert aufgeführt. + +{% endif %} + +{% else %} +\vspace{110pt} +{% endif %} + + +\vspace{12pt} + +\noindent\textbf{\large Ausgabenübersicht} +\nopagebreak +\begin{table}[H] + \centering + \begin{tabularx}{.97\textwidth}{lXlr} + \toprule + \textbf{Titel} & \textbf{Beschreibung} & \textbf{Auszahlung an} & \textbf{Betrag} \\ + \midrule + +{% if statement.bills_covered %} + {% for bill in statement.bills_covered %} + {{ forloop.counter }}. {{ bill.short_description}} & {{ bill.explanation}} & {{ bill.paid_by.name|esc_all }} & {{ bill.amount }} € \\ + {% endfor %} + \midrule + \multicolumn{3}{l}{\textbf{Summe übernommene Ausgaben}} & \textbf{ {{ statement.total_bills }} }€\\ +{% endif %} +{% if excursion.approved_staff_count > 0 and statement.allowances_paid > 0 or excursion.approved_staff_count > 0 and statement.subsidy_to %} + \midrule + {% if statement.allowances_paid > 0 %} + {% for m in statement.allowance_to.all %} + Aufwandsentschädigung & & {{ m.name|esc_all }} & {{ statement.allowance_per_yl }} €\\ + {% endfor %} + {% endif %} + {% if statement.subsidy_to %} + \multicolumn{2}{l}{Zuschuss Übernachtung und Anreise für alle Jugendleiter*innen} & {{ statement.subsidy_to.name|esc_all }} & {{ statement.total_subsidies }} €\\ + {% endif %} + \midrule + \multicolumn{3}{l}{\textbf{Summe Zuschüsse und Aufwandsentschädigung Jugendleitende}} & \textbf{ {{ statement.total_staff }} }€\\ +{%endif %} +{% if statement.ljp_to %} + \midrule + LJP-Zuschuss für die Teilnehmenden && {{ statement.ljp_to.name|esc_all }} & {{ statement.paid_ljp_contributions|esc_all }} €\\ + +{% endif %} +{% if statement.ljp_to or statement.bills_covered and excursion.approved_staff_count > 0 %} + \midrule + \textbf{Gesamtsumme}& & & \textbf{ {{ statement.total }} }€\\ +{% endif %} + \bottomrule + \end{tabularx} +\end{table} + +\noindent Dieser Beleg wird automatisch erstellt und daher nicht unterschrieben. + +{% endblock %} diff --git a/jdav_web/jdav_web/settings/local.py b/jdav_web/jdav_web/settings/local.py index 20c8c13..7d7ebcc 100644 --- a/jdav_web/jdav_web/settings/local.py +++ b/jdav_web/jdav_web/settings/local.py @@ -20,6 +20,7 @@ DIGITAL_MAIL = get_var('section', 'digital_mail', default='bar@example.org') V32_HEAD_ORGANISATION = get_var('LJP', 'v32_head_organisation', default='not configured') LJP_CONTRIBUTION_PER_DAY = get_var('LJP', 'contribution_per_day', default=25) +LJP_TAX = get_var('LJP', 'tax', default=0) # echo diff --git a/jdav_web/locale/de/LC_MESSAGES/django.po b/jdav_web/locale/de/LC_MESSAGES/django.po index cafea14..1adcf4e 100644 --- a/jdav_web/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/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-01 14:54+0100\n" +"POT-Creation-Date: 2025-04-06 19:10+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -236,6 +236,10 @@ msgstr "Löschen?" msgid "Unconfirm" msgstr "Bestätigung zurücknehmen" +#: templates/admin/finance/statementconfirmed/change_form_object_tools.html +msgid "Download summary" +msgstr "Beleg herunterladen" + #: templates/admin/finance/statementsubmitted/change_form_object_tools.html msgid "Reduce transactions" msgstr "Überweisungen minimieren" diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index ea45863..c61fb6b 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -898,10 +898,11 @@ class StatementOnListForm(forms.ModelForm): # of subsidies and allowance self.fields['allowance_to'].queryset = excursion.jugendleiter.all() self.fields['subsidy_to'].queryset = excursion.jugendleiter.all() + self.fields['ljp_to'].queryset = excursion.jugendleiter.all() class Meta: model = Statement - fields = ['night_cost', 'allowance_to', 'subsidy_to'] + fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to'] def clean(self): """Check if the `allowance_to` and `subsidy_to` fields are compatible with @@ -922,7 +923,7 @@ class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedIn extra = 1 description = _('Please list here all expenses in relation with this excursion and upload relevant bills. These have to be permanently stored for the application of LJP contributions. The short descriptions are used in the seminar report cost overview (possible descriptions are e.g. food, material, etc.).') sortable_options = [] - fields = ['night_cost', 'allowance_to', 'subsidy_to'] + fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to'] inlines = [BillOnExcursionInline] form = StatementOnListForm @@ -1229,6 +1230,12 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): messages.error(request, _("The configured recipients of the allowance don't match the regulations. Please correct this and try again.")) return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(memberlist.pk,))) + + if memberlist.statement.ljp_to and len(memberlist.statement.bills_without_proof) > 0: + messages.error(request, + _("The excursion is configured to claim LJP contributions. In that case, for all bills, a proof must be uploaded. Please correct this and try again.")) + return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(memberlist.pk,))) + memberlist.statement.submit(get_member(request)) messages.success(request, _("Successfully submited statement. The finance department will notify you as soon as possible.")) @@ -1238,8 +1245,7 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): opts=self.opts, memberlist=memberlist, object=memberlist, - participant_count=memberlist.participant_count, - ljp_contributions=memberlist.potential_ljp_contributions, + ljp_contributions=memberlist.payable_ljp_contributions, total_relative_costs=memberlist.total_relative_costs, **memberlist.statement.template_context()) return render(request, 'admin/freizeit_finance_overview.html', context=context) diff --git a/jdav_web/members/excel.py b/jdav_web/members/excel.py index 20bbbc1..d8b55e3 100644 --- a/jdav_web/members/excel.py +++ b/jdav_web/members/excel.py @@ -117,7 +117,7 @@ def generate_ljp_vbk(excursion): sheet['D19'] = settings.SEKTION sheet['G19'] = title sheet['I19'] = f"von {excursion.date:%d.%m.%y} bis {excursion.end:%d.%m.%y}" - sheet['J19'] = excursion.duration + sheet['J19'] = excursion.ljp_duration sheet['L19'] = f"{excursion.ljp_participant_count}" sheet['H19'] = excursion.get_ljp_activity_category() sheet['M19'] = f"{excursion.postcode}, {excursion.place}" diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index e2d3d42..2e9381a 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-04-06 15:38+0200\n" +"POT-Creation-Date: 2025-04-06 18:57+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -417,6 +417,15 @@ msgstr "" "Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen nicht mit " "den Richtlinien überein. Bitte korrigiere das und versuche es erneut. " +#: members/admin.py +msgid "" +"The excursion is configured to claim LJP contributions. In that case, for " +"all bills, a proof must be uploaded. Please correct this and try again." +msgstr "" +"Für die Ausfahrt werden LJP-Zuschüsse beantragt. Dafür müssen für alle " +"Ausgaben Belege hochgeladen werden. Bitte lade für alle Ausgaben einen Beleg " +"hoch und versuche es erneut. " + #: members/admin.py msgid "" "Successfully submited statement. The finance department will notify you as " @@ -1354,6 +1363,28 @@ msgstr "" msgid "LJP contributions" msgstr "LJP Zuschüsse" +#: members/templates/admin/freizeit_finance_overview.html +#, python-format +msgid "" +"By submitting the given seminar report, you will receive LJP contributions.\n" +"You have documented interventions worth of %(total_seminar_days)s seminar " +"days for %(participant_count)s participants.\n" +"This results in a total contribution of %(ljp_contributions)s€.\n" +"To receive them, you need to submit the LJP-Proposal within 3 weeks after " +"your excursion and have it approved by the finance office." +msgstr "" +"Wenn du den erstellten LJP-Antrag einreichst, erhältst du LJP-Zuschüsse. Du " +"hast Lehreinheiten für insgesamt %(total_seminar_days)s Seminartage und für " +"%(participant_count)s Teilnehmende dokumentiert.\n" +"Daraus ergibt sich ein auszahlbarer LJP-Zuschuss von " +"%(ljp_contributions)s€. Um den zu erhalten, musst du den LJP-Antrag " +"innerhalb von 3 Wochen nach der Ausfahrt beim Jugendreferat einreichen und " +"formal genehmigt bekommen." + +#: members/templates/admin/freizeit_finance_overview.html +msgid "The LJP contributions are configured to be paid to:" +msgstr "Die LJP-Zuschüsse werden ausgezahlt an:" + #: members/templates/admin/freizeit_finance_overview.html #, python-format msgid "" @@ -1361,13 +1392,17 @@ msgid "" "case,\n" "you may obtain up to 25€ times %(duration)s days for %(participant_count)s " "participants but only up to\n" -"90%% of the total costs. This results in a total of %(ljp_contributions)s€." +"90%% of the total costs. This results in a total of %(ljp_contributions)s€. " +"If you have created a seminar report, you need to specify who should receive " +"the contributions in order to make use of them." msgstr "" "Indem du einen Seminarbericht anfertigst, kannst du Landesjugendplan (LJP) " "Zuschüsse beantragen. In diesem Fall kannst du bis zu 25€ mal %(duration)s " "Tage für %(participant_count)s Teilnehmende, aber nicht mehr als 90%% der " "Gesamtausgaben erhalten. Das resultiert in einem Gesamtzuschuss von " -"%(ljp_contributions)s€." +"%(ljp_contributions)s€. Wenn du schon einen Seminarbericht erstellt hast, " +"musst du im Tab 'Abrechnungen' noch angeben, an wen die LJP-Zuschüsse " +"ausgezahlt werden sollen." #: members/templates/admin/freizeit_finance_overview.html msgid "Summary" diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 7e6eecc..dd617f2 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -8,6 +8,7 @@ import csv from django.db import models from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\ Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef +from django.db.models.functions import TruncDate from django.utils.translation import gettext_lazy as _ from django.utils import timezone from django.utils.html import format_html @@ -1278,16 +1279,40 @@ class Freizeit(CommonModel): else: return 0 + @property + def total_seminar_days(self): + """calculate seminar days based on intervention hours in every day""" + # TODO: add tests for this + if hasattr(self, 'ljpproposal'): + hours_per_day = ( + self.ljpproposal.intervention_set + .annotate(day=TruncDate('date_start')) # Extract the date (without time) + .values('day') # Group by day + .annotate(total_duration=Sum('duration')) # Sum durations for each day + .order_by('day') # Sort results by date + ) + # Calculate the total number of seminar days + # Each day is counted as 1 if total_duration is >= 5 hours, as 0.5 if total_duration is >= 2.5 + # otherwise 0 + return sum([min(math.floor(h['total_duration']/cvt_to_decimal(2.5))/2, 1) for h in hours_per_day]) + else: + return 0 + + @property + def ljp_duration(self): + """calculate the duration in days for the LJP""" + return min(self.duration, self.total_seminar_days) + @property def staff_count(self): return self.jugendleiter.count() - + @property def staff_on_memberlist(self): ps = set(map(lambda x: x.member, self.membersonlist.distinct())) jls = set(self.jugendleiter.distinct()) return ps.intersection(jls) - + @property def staff_on_memberlist_count(self): return len(self.staff_on_memberlist) @@ -1297,7 +1322,7 @@ class Freizeit(CommonModel): ps = set(map(lambda x: x.member, self.membersonlist.distinct())) jls = set(self.jugendleiter.distinct()) return len(ps - jls) - + @property def head_count(self): return self.staff_on_memberlist_count + self.participant_count @@ -1366,12 +1391,19 @@ class Freizeit(CommonModel): return cvt_to_decimal(min(self.maximal_ljp_contributions, 0.9 * float(self.statement.total_bills_theoretic) + float(self.statement.total_staff))) + @property + def payable_ljp_contributions(self): + """from the requested ljp contributions, a tax may be deducted for risk reduction""" + if self.statement.ljp_to: + return self.statement.paid_ljp_contributions + return cvt_to_decimal(self.potential_ljp_contributions * cvt_to_decimal(1 - settings.LJP_TAX)) + @property def total_relative_costs(self): if not self.statement: return 0 total_costs = self.statement.total_bills_theoretic - total_contributions = self.statement.total_subsidies + self.potential_ljp_contributions + total_contributions = self.statement.total_subsidies + self.payable_ljp_contributions return total_costs - total_contributions @property diff --git a/jdav_web/members/pdf.py b/jdav_web/members/pdf.py index fdd82f0..57e414a 100644 --- a/jdav_web/members/pdf.py +++ b/jdav_web/members/pdf.py @@ -47,6 +47,23 @@ def render_docx(name, template_path, context, date=None, save_only=False): return filename_docx return serve_media(filename_docx, 'application/docx') +def render_tex_with_attachments(name, template_path, context, attachments, save_only=False): + + rendered_pdf = render_tex(name, template_path, context, save_only=True) + + reader = PdfReader(media_path(rendered_pdf)) + writer = PdfWriter() + writer.append(reader) + + pdf_add_attachments(writer, attachments) + + with open(media_path(rendered_pdf), 'wb') as output_stream: + writer.write(output_stream) + + if save_only: + return rendered_pdf + return serve_pdf(rendered_pdf) + def render_tex(name, template_path, context, date=None, save_only=False): filename = generate_tex(name, template_path, context, date=date) @@ -73,6 +90,27 @@ def render_tex(name, template_path, context, date=None, save_only=False): return serve_pdf(filename_pdf) +def pdf_add_attachments(pdf_writer, attachments): + for fp in attachments: + try: + if fp.endswith(".pdf"): + # append pdf directly + img_pdf = PdfReader(fp) + else: + # convert ensures that png files with an alpha channel can be appended + img = Image.open(fp).convert("RGB") + img_io = BytesIO() + img.save(img_io, "pdf") + img_io.seek(0) + img_pdf = PdfReader(img_io) + img_pdf_scaled = scale_pdf_to_a4(img_pdf) + pdf_writer.append(img_pdf_scaled) + + except Exception as e: + print("Could not add image", fp) + print(e) + + def scale_pdf_page_to_a4(page): A4_WIDTH, A4_HEIGHT = 595, 842 @@ -114,23 +152,7 @@ def fill_pdf_form(name, template_path, fields, attachments=[], date=None, save_o writer.update_page_form_field_values(None, fields, auto_regenerate=False) - for fp in attachments: - try: - if fp.endswith(".pdf"): - # append pdf directly - img_pdf = PdfReader(fp) - else: - # convert ensures that png files with an alpha channel can be appended - img = Image.open(fp).convert("RGB") - img_io = BytesIO() - img.save(img_io, "pdf") - img_io.seek(0) - img_pdf = PdfReader(img_io) - img_pdf_scaled = scale_pdf_to_a4(img_pdf) - writer.append(img_pdf_scaled) - except Exception as e: - print("Could not add image", fp) - print(e) + pdf_add_attachments(writer, attachments) with open(media_path(filename_pdf), 'wb') as output_stream: writer.write(output_stream) diff --git a/jdav_web/members/templates/admin/freizeit_finance_overview.html b/jdav_web/members/templates/admin/freizeit_finance_overview.html index 3a68dfe..aecc588 100644 --- a/jdav_web/members/templates/admin/freizeit_finance_overview.html +++ b/jdav_web/members/templates/admin/freizeit_finance_overview.html @@ -130,14 +130,39 @@ cost plan!

{% endif %} + +{% if memberlist.statement.ljp_to %}

{% trans "LJP contributions" %}

+

+{% 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 %} +

+ +

+ {% blocktrans %}The LJP contributions are configured to be paid to:{% endblocktrans %} + + + + + + + +
+ {% trans "IBAN valid" %}
{{ memberlist.statement.ljp_to.name }}{{ memberlist.statement.ljp_to.iban_valid|render_bool }}
+

+ +{% else %}

{% blocktrans %}By submitting a seminar report, you may apply for LJP contributions. In this case, you may obtain up to 25€ times {{ duration }} days for {{ participant_count }} participants but only up to -90% of the total costs. This results in a total of {{ ljp_contributions }}€.{% endblocktrans %} +90% of the total costs. This results in a total of {{ ljp_contributions }}€. If you have created a seminar report, you need to specify who should receive the contributions in order to make use of them.{% endblocktrans %}

+{% endif %} +

{% trans "Summary" %}

@@ -163,7 +188,7 @@ you may obtain up to 25€ times {{ duration }} days for {{ participant_count }} - {% trans "Potential LJP contributions" %} + {% if memberlist.statement.ljp_to %}{% trans "LJP contributions" %}{% else %}{% trans "Potential LJP contributions" %}{% endif %} -{{ ljp_contributions }}€ diff --git a/jdav_web/members/templates/members/seminar_report_docx.tex b/jdav_web/members/templates/members/seminar_report_docx.tex index 21c58b8..73539a2 100644 --- a/jdav_web/members/templates/members/seminar_report_docx.tex +++ b/jdav_web/members/templates/members/seminar_report_docx.tex @@ -36,7 +36,7 @@ \textbf{Sektion:} & {{ settings.SEKTION }} \\ \textbf{Titel der Maßnahme:} & {% if not memberlist.ljpproposal %}{{ memberlist.name|esc_all }}{% else %}{{ memberlist.ljpproposal.title }} {% endif %} \\ \textbf{Interne Ordnungsnummer:} & {{ memberlist.code|esc_all }} \\ - \textbf{Anzahl der durchgeführten Lehrgangstage:} & {{ memberlist.duration }} \\ + \textbf{Anzahl der durchgeführten Lehrgangstage:} & {{ memberlist.ljp_duration }} \\ \end{tabular} \end{table} diff --git a/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html b/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html index 1906f52..cb53b47 100644 --- a/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html +++ b/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html @@ -7,7 +7,10 @@ {% url opts|admin_urlname:'unconfirm' original.pk|admin_urlquote as invite_url %} {% trans 'Unconfirm' %} - +

  • + {% url opts|admin_urlname:'summary' original.pk|admin_urlquote as invite_url %} + {% trans 'Download summary' %} +
  • {{block.super}} {% endblock %} From 60942115ca0efe84ef967506e6ecdba601dd25c9 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 7 Apr 2025 01:36:46 +0200 Subject: [PATCH 10/18] fix(members/rules): check for groups of excursion instead of groups of member --- jdav_web/members/rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdav_web/members/rules.py b/jdav_web/members/rules.py index c45a032..4193f68 100644 --- a/jdav_web/members/rules.py +++ b/jdav_web/members/rules.py @@ -60,7 +60,7 @@ def _is_leader(member, excursion): return False if member in excursion.jugendleiter.all(): 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 From 62a4e37a158a1f0f3d5e80c770b0cde681896b79 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Thu, 10 Apr 2025 23:48:16 +0200 Subject: [PATCH 11/18] feat(members/admin): re-request mail confirmation for waiters --- jdav_web/members/admin.py | 15 +++++++++- .../members/locale/de/LC_MESSAGES/django.po | 30 +++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index c61fb6b..c7971ed 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -643,7 +643,8 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): 'confirmed_mail', 'waiting_confirmed', 'sent_reminders') search_fields = ('prename', 'lastname', 'email') 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] readonly_fields= ['application_date', 'sent_reminders'] @@ -671,6 +672,18 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): args=(waiter.pk,))) 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): urls = super().get_urls() diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 2e9381a..1876643 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-04-06 18:57+0200\n" +"POT-Creation-Date: 2025-04-10 23:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -222,6 +222,27 @@ msgstr "Erfolgreich %(name)s aufgefordert den Wartelistenplatz zu bestätigen." msgid "Ask selected waiters to confirm their waiting status" 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 msgid "Offer waiter a place in a group." msgstr "Personen auf der Warteliste einen Gruppenplatz anbieten." @@ -1376,10 +1397,9 @@ 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." +"Daraus ergibt sich ein auszahlbarer LJP-Zuschuss von %(ljp_contributions)s€. " +"Um den zu erhalten, musst du den LJP-Antrag innerhalb von 3 Wochen nach der " +"Ausfahrt beim Jugendreferat einreichen und formal genehmigt bekommen." #: members/templates/admin/freizeit_finance_overview.html msgid "The LJP contributions are configured to be paid to:" From 2fcdf1ee254217dfd41dc63756b7eaa4813af351 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Thu, 10 Apr 2025 23:56:18 +0200 Subject: [PATCH 12/18] feat(members/admin): add unconfirm member action --- jdav_web/members/admin.py | 8 +++++++- jdav_web/members/locale/de/LC_MESSAGES/django.po | 11 ++++++++++- jdav_web/members/models.py | 4 ++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index c7971ed..c33caa8 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -240,7 +240,7 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): } change_form_template = "members/change_member.html" ordering = ('lastname',) - actions = ['request_echo', 'invite_as_user_action'] + actions = ['request_echo', 'invite_as_user_action', 'unconfirm'] list_per_page = 25 form = MemberAdminForm @@ -409,6 +409,12 @@ class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): name_text_or_link.short_description = _('Name') 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): _selected_action = forms.CharField(widget=forms.MultipleHiddenInput) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 1876643..095088d 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-04-10 23:31+0200\n" +"POT-Creation-Date: 2025-04-10 23:51+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -133,6 +133,15 @@ msgstr "Aktivität" msgid "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 msgid "Successfully requested mail confirmation from selected registrations." msgstr "Aufforderung zur Bestätigung der Email Adresse versendet." diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index dd617f2..8d99151 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -414,6 +414,10 @@ class Member(Person): self.save() return True + def unconfirm(self): + self.confirmed = False + self.save() + def unsubscribe(self, key): if self.unsubscribe_key == key and timezone.now() <\ self.unsubscribe_expire: From fb14c2f060908d0c4382d1e2cf42b8efceacee23 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Fri, 11 Apr 2025 00:53:45 +0200 Subject: [PATCH 13/18] feat(members/views): show upload registration form after echo if missing --- .../members/locale/de/LC_MESSAGES/django.po | 47 +++++++++++++++---- jdav_web/members/models.py | 5 ++ .../members/upload_registration_form.html | 17 +++++++ .../upload_registration_form_success.html | 14 +++++- jdav_web/members/tests.py | 5 +- jdav_web/members/views.py | 14 ++++-- 6 files changed, 88 insertions(+), 14 deletions(-) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 095088d..58b13f6 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-04-10 23:51+0200\n" +"POT-Creation-Date: 2025-04-11 00:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -140,7 +140,8 @@ msgstr "" #: members/admin.py msgid "Unconfirm selected members." -msgstr "Ausgewählte Teilnehmer*innen zu unbestätigten Registrierungen zurücksetzen." +msgstr "" +"Ausgewählte Teilnehmer*innen zu unbestätigten Registrierungen zurücksetzen." #: members/admin.py members/tests.py msgid "Successfully requested mail confirmation from selected registrations." @@ -1787,6 +1788,8 @@ msgstr "" #: members/templates/members/echo_password.html #: members/templates/members/echo_success.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" msgstr "Rückmeldung" @@ -2109,6 +2112,25 @@ msgstr "" "abgelehnt. Wenn ein Platz in einer anderen Gruppe frei wird, erhältst du " "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 download\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 " +"lade das Formular herunter, " +"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 #, python-format msgid "" @@ -2141,13 +2163,14 @@ msgid "The supplied key for uploading a registration form is invalid." msgstr "Der verwendete Link zum Hochladen eines Anmeldeformulars ist ungültig." #: members/templates/members/upload_registration_form_success.html -#: members/tests.py -msgid "" -"Thank you for uploading the registration form. Our team will process your " -"registration shortly." +msgid "Thank you for uploading the registration form." +msgstr "Danke für das Hochladen des Anmeldeformulars." + +#: members/templates/members/upload_registration_form_success.html +msgid "Our team will process your registration shortly." msgstr "" -"Danke für das Hochladen des Anmeldeformulars. Unser Jugendleiterteam wird " -"deine Registrierung so schnell wie möglich bearbeiten." +"Unser Jugendleiter*innenteam wird deine Registrierung so schnell wie möglich " +"bearbeiten." #: members/templates/members/waiting_confirmation_invalid.html msgid "Waiting confirmation failed" @@ -2195,6 +2218,14 @@ msgstr "" msgid "The entered password is wrong." msgstr "Das eingegebene Passwort ist falsch." +#: members/tests.py +msgid "" +"Thank you for uploading the registration form. Our team will process your " +"registration shortly." +msgstr "" +"Danke für das Hochladen des Anmeldeformulars. Unser Jugendleiterteam wird " +"deine Registrierung so schnell wie möglich bearbeiten." + #: members/tests.py members/views.py msgid "invalid" msgstr "ungültig" diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 8d99151..aedcf49 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -354,6 +354,7 @@ class Member(Person): help_text=_('If the person registered from the waitinglist, this is their application date.')) objects = MemberManager() + all_objects = models.Manager() @property def email_fields(self): @@ -509,6 +510,10 @@ class Member(Person): # get activity overview 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): """Given a member, a corresponding waiting-list object and a group, this completes the registration and requests email confirmations if necessary. diff --git a/jdav_web/members/templates/members/upload_registration_form.html b/jdav_web/members/templates/members/upload_registration_form.html index 9846542..3e12646 100644 --- a/jdav_web/members/templates/members/upload_registration_form.html +++ b/jdav_web/members/templates/members/upload_registration_form.html @@ -3,19 +3,36 @@ {% load static %} {% block title %} +{% if member.confirmed %} +{% trans "Echo" %} +{% else %} {% trans "Registration" %} +{% endif %} {% endblock %} {% block content %} +{% if member.confirmed %} +

    {% trans "Echo" %}

    +{% else %}

    {% trans "Register" %}

    +{% endif %} {% url 'members:download_registration_form' as download_url %} +{% if member.confirmed %} +

    {% 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 download +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 %} +

    +{% else %}

    {% blocktrans %}We summarized your registration in our registration form. Please download it, 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 %}

    +{% endif %}

    {% blocktrans %}If you are not an adult yet, please let someone responsible for you sign the agreement.{% endblocktrans %}

    diff --git a/jdav_web/members/templates/members/upload_registration_form_success.html b/jdav_web/members/templates/members/upload_registration_form_success.html index 30f9aeb..e593f4c 100644 --- a/jdav_web/members/templates/members/upload_registration_form_success.html +++ b/jdav_web/members/templates/members/upload_registration_form_success.html @@ -3,14 +3,26 @@ {% load static %} {% block title %} +{% if member.confirmed %} +{% trans "Echo" %} +{% else %} {% trans "Registration" %} +{% endif %} {% endblock %} {% block content %} +{% if member.confirmed %} +

    {% trans "Echo" %}

    +{% else %}

    {% trans "Register" %}

    +{% endif %} -

    {% blocktrans %}Thank you for uploading the registration form. Our team will process your registration shortly.{% endblocktrans %} +

    +{% blocktrans %}Thank you for uploading the registration form.{% endblocktrans %} +{% if not member.confirmed %} +{% blocktrans %}Our team will process your registration shortly.{% endblocktrans %} +{% endif %}

    {% endblock %} diff --git a/jdav_web/members/tests.py b/jdav_web/members/tests.py index 3e90a67..55c7211 100644 --- a/jdav_web/members/tests.py +++ b/jdav_web/members/tests.py @@ -1354,7 +1354,7 @@ class UploadRegistrationFormViewTestCase(BasicMemberTestCase): }) self.assertEqual(response.status_code, HTTPStatus.OK) 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): def setUp(self): @@ -1597,6 +1597,9 @@ class EchoViewTestCase(BasicMemberTestCase): def setUp(self): super().setUp() 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): self.assertEqual(response.status_code, HTTPStatus.OK) diff --git a/jdav_web/members/views.py b/jdav_web/members/views.py index 7f9abee..a4789d6 100644 --- a/jdav_web/members/views.py +++ b/jdav_web/members/views.py @@ -180,7 +180,13 @@ def echo(request): # member.echo_key, member.echo_expire = "", timezone.now() member.echoed = True 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: # when input is invalid form = MemberForm(request.POST) @@ -305,7 +311,7 @@ def download_registration_form(request): return render_upload_registration_form_invalid(request) key = request.GET['key'] 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) except Member.DoesNotExist: return render_upload_registration_form_invalid(request) @@ -332,7 +338,7 @@ def upload_registration_form(request): return render_upload_registration_form_invalid(request) key = request.GET['key'] try: - member = MemberUnconfirmedProxy.objects.get(upload_registration_form_key=key) + member = Member.all_objects.get(upload_registration_form_key=key) except Member.DoesNotExist: return render_upload_registration_form_invalid(request) form = UploadRegistrationForm(instance=member) @@ -341,7 +347,7 @@ def upload_registration_form(request): return render_upload_registration_form_invalid(request) key = request.POST['key'] try: - member = MemberUnconfirmedProxy.objects.get(upload_registration_form_key=key) + member = Member.all_objects.get(upload_registration_form_key=key) except Member.DoesNotExist: return render_upload_registration_form_invalid(request) From 6b32595a5f86dc005126f2112aa6974cf9c15047 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Fri, 11 Apr 2025 01:01:45 +0200 Subject: [PATCH 14/18] chore(members/views): modify texts and button size --- .../members/locale/de/LC_MESSAGES/django.po | 40 +++++++++---------- jdav_web/members/templates/members/echo.html | 2 +- .../templates/members/member_form.html | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 58b13f6..108b36f 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-04-11 00:39+0200\n" +"POT-Creation-Date: 2025-04-11 00:56+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1793,11 +1793,13 @@ msgstr "" msgid "Echo" msgstr "Rückmeldung" -#: members/templates/members/echo.html members/tests.py -msgid "Thanks for echoing back. Here is your current data:" +#: members/templates/members/echo.html +msgid "" +"Here is your current data. Please check if it is up to date and change " +"accordingly." msgstr "" -"Vielen Dank, dass du dich rückmeldest. Hier siehst du deine aktuellen Daten. " -"Falls sich etwas geändert hat, trage das bitte hier ein." +"Hier siehst du deine aktuellen Daten. Bitte überprüfe alles und passe es " +"bei Bedarf an." #: members/templates/members/echo_failed.html members/tests.py msgid "Echo failed" @@ -2123,13 +2125,12 @@ msgid "" "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 " -"lade das Formular herunter, " -"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." +"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 lade " +"das Formular herunter, 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 #, python-format @@ -2167,6 +2168,7 @@ 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/tests.py msgid "Our team will process your registration shortly." msgstr "" "Unser Jugendleiter*innenteam wird deine Registrierung so schnell wie möglich " @@ -2218,14 +2220,6 @@ msgstr "" msgid "The entered password is wrong." msgstr "Das eingegebene Passwort ist falsch." -#: members/tests.py -msgid "" -"Thank you for uploading the registration form. Our team will process your " -"registration shortly." -msgstr "" -"Danke für das Hochladen des Anmeldeformulars. Unser Jugendleiterteam wird " -"deine Registrierung so schnell wie möglich bearbeiten." - #: members/tests.py members/views.py msgid "invalid" msgstr "ungültig" @@ -2234,6 +2228,12 @@ msgstr "ungültig" msgid "expired" msgstr "abgelaufen" +#: members/tests.py +msgid "Thanks for echoing back. Here is your current data:" +msgstr "" +"Vielen Dank, dass du dich rückmeldest. Hier siehst du deine aktuellen Daten. " +"Falls sich etwas geändert hat, trage das bitte hier ein." + #: members/views.py msgid "Prename of the member." msgstr "Vorname des*der Teilnehmenden" diff --git a/jdav_web/members/templates/members/echo.html b/jdav_web/members/templates/members/echo.html index b12b38f..3737663 100644 --- a/jdav_web/members/templates/members/echo.html +++ b/jdav_web/members/templates/members/echo.html @@ -12,7 +12,7 @@

    {% trans "Echo" %}

    -

    {% trans "Thanks for echoing back. Here is your current data:" %}

    +

    {% trans "Here is your current data. Please check if it is up to date and change accordingly." %}

    {% if error_message %}

    {{ error_message }}

    diff --git a/jdav_web/members/templates/members/member_form.html b/jdav_web/members/templates/members/member_form.html index e357cfa..1ba2981 100644 --- a/jdav_web/members/templates/members/member_form.html +++ b/jdav_web/members/templates/members/member_form.html @@ -34,7 +34,7 @@ -

    +