From 1ada10fda47a5d5db8595c6d5b13df812f936cee Mon Sep 17 00:00:00 2001
From: Christian Merten
Date: Sat, 3 May 2025 18:29:13 +0200
Subject: [PATCH 1/3] feat(members/excursion): automatically send crisis
intervention list
---
.../jdav_web/settings/components/texts.py | 26 +++++++
.../members/locale/de/LC_MESSAGES/django.po | 26 +++++--
..._crisis_intervention_list_sent_and_more.py | 23 +++++++
jdav_web/members/models.py | 69 +++++++++++++++++--
jdav_web/members/tasks.py | 30 +++++++-
jdav_web/members/tests.py | 20 ++++++
jdav_web/utils.py | 8 +++
7 files changed, 193 insertions(+), 9 deletions(-)
create mode 100644 jdav_web/members/migrations/0041_freizeit_crisis_intervention_list_sent_and_more.py
diff --git a/jdav_web/jdav_web/settings/components/texts.py b/jdav_web/jdav_web/settings/components/texts.py
index 69743dc..e70e3c5 100644
--- a/jdav_web/jdav_web/settings/components/texts.py
+++ b/jdav_web/jdav_web/settings/components/texts.py
@@ -247,3 +247,29 @@ Deine JDAV %(SEKTION)s""" % { 'SEKTION': SEKTION, 'RESPONSIBLE_MAIL': RESPONSIBL
ADDRESS = get_text('address', default="""JDAV %(SEKTION)s
%(STREET)s
%(PLACE)s""" % { 'SEKTION': SEKTION, 'STREET': SEKTION_STREET, 'PLACE': SEKTION_TOWN })
+
+
+NOTIFY_EXCURSION_PARTICIPANT_LIST = get_text('notify_excursion_participant_list', default="""Hallo {name},
+
+deine Ausfahrt {excursion} steht kurz bevor. Damit die Sektion dich im Notfall gut unterstützen kann, benötigt
+die Geschäftsstelle eine aktuelle Kriseninterventionsliste, das heißt eine Teilnehmendenliste der Ausfahrt.
+
+Das Verschicken der Liste passiert automatisch zum Zeitpunkt: {sending_time}.
+
+Das sind die aktuell in der Ausfahrt eingetragenen Teilnehmenden:
+{participants}
+
+Falls diese Liste nicht mehr aktuell ist, gehe bitte umgehend auf {excursion_link} und trage die Daten nach.
+
+Viele Grüße
+Dein KOMPASS""")
+
+SEND_EXCURSION_CRISIS_LIST = get_text('send_excursion_crisis_list', default="""Hallo zusammen,
+
+vom {excursion_start} bis {excursion_end} findet die Ausfahrt {excursion} der Jugend statt. Die
+Ausfahrt wird geleitet von {leaders}.
+
+Im Anhang findet ihr die Kriseninterventionsliste.
+
+Viele Grüße
+Euer KOMPASS""")
diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po
index 3a5cb55..8a7f47a 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-27 23:00+0200\n"
+"POT-Creation-Date: 2025-05-03 18:06+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -469,6 +469,15 @@ msgstr ""
msgid "Finance overview"
msgstr "Kostenübersicht"
+#: members/admin.py
+msgid "Inform youth leaders about sending of crisis intervention list."
+msgstr ""
+"Informiere Jugendleiter:innen über Versand der Kriseninterventionsliste."
+
+#: members/admin.py
+msgid "Send crisis intervention list."
+msgstr "Kriseninterventionsliste verschicken"
+
#: members/apps.py
msgid "member administration"
msgstr "Teilnehmer*innenverwaltung"
@@ -993,6 +1002,15 @@ msgstr "Ausfahrt"
msgid "Excursions"
msgstr "Ausfahrten"
+#: members/models.py
+msgid "Crisis intervention list for %(excursion)s from %(start)s to %(end)s"
+msgstr "Kriseninterventionsliste für %(excursion)s vom %(start)s bis %(end)s"
+
+#: members/models.py
+#, python-format
+msgid "Participant list for %(excursion)s from %(start)s to %(end)s"
+msgstr "Teilnehmendenliste für %(excursion)s vom %(start)s bis %(end)s"
+
#: members/models.py
msgid "Title"
msgstr "Titel"
@@ -1392,9 +1410,9 @@ msgid ""
"%(total_org_fee_theoretical)s € is charged against the other transactions."
msgstr ""
"Achtung: %(old_participant_count)s Teilnehmende der Ausfahrt sind 27 oder "
-"älter. Für diese Teilnehmende(n) ist ein Org-Beitrag von %(org_fee)s € pro Tag "
-"fällig. Durch die Länge der Ausfahrt von %(duration)s Tagen werden insgesamt "
-"%(total_org_fee_theoretical)s € mit den Zuschüssen und "
+"älter. Für diese Teilnehmende(n) ist ein Org-Beitrag von %(org_fee)s € pro "
+"Tag fällig. Durch die Länge der Ausfahrt von %(duration)s Tagen werden "
+"insgesamt %(total_org_fee_theoretical)s € mit den Zuschüssen und "
"Aufwandsentschädigungen verrechnet, sofern diese in Anspruch genommen werden."
#: members/templates/admin/freizeit_finance_overview.html
diff --git a/jdav_web/members/migrations/0041_freizeit_crisis_intervention_list_sent_and_more.py b/jdav_web/members/migrations/0041_freizeit_crisis_intervention_list_sent_and_more.py
new file mode 100644
index 0000000..6904d1e
--- /dev/null
+++ b/jdav_web/members/migrations/0041_freizeit_crisis_intervention_list_sent_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.20 on 2025-05-03 15:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('members', '0040_invitationtogroup_created_by'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='freizeit',
+ name='crisis_intervention_list_sent',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='freizeit',
+ name='notification_crisis_intervention_list_sent',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py
index 2542b46..2e84423 100644
--- a/jdav_web/members/models.py
+++ b/jdav_web/members/models.py
@@ -26,10 +26,12 @@ from django.conf import settings
from django.core.validators import MinValueValidator
from .rules import may_view, may_change, may_delete, is_own_training, is_oneself, is_leader, is_leader_of_excursion
+from .pdf import render_tex
import rules
from contrib.models import CommonModel
+from contrib.media import media_path
from contrib.rules import memberize_user, has_global_perm
-from utils import cvt_to_decimal
+from utils import cvt_to_decimal, coming_midnight
from dateutil.relativedelta import relativedelta
from schwifty import IBAN
@@ -1217,6 +1219,10 @@ class Freizeit(CommonModel):
approval_comments = models.TextField(verbose_name=_('Approval comments'),
blank=True, default='')
+ # automatic sending of crisis intervention list
+ crisis_intervention_list_sent = models.BooleanField(default=False)
+ notification_crisis_intervention_list_sent = models.BooleanField(default=False)
+
def __str__(self):
"""String represenation"""
return self.name
@@ -1345,18 +1351,18 @@ class Freizeit(CommonModel):
@property
def participant_count(self):
return len(self.participants)
-
+
@property
def participants(self):
ps = set(map(lambda x: x.member, self.membersonlist.distinct()))
jls = set(self.jugendleiter.distinct())
return list(ps - jls)
-
+
@property
def old_participant_count(self):
old_ps = [m for m in self.participants if m.age() >= 27]
return len(old_ps)
-
+
@property
def head_count(self):
return self.staff_on_memberlist_count + self.participant_count
@@ -1609,6 +1615,61 @@ class Freizeit(CommonModel):
queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter__pk=member.pk)).distinct()
return queryset
+ def send_crisis_intervention_list(self, sending_time=None):
+ """
+ Send the crisis intervention list to the crisis invervention email, the
+ responsible and the youth leaders of this excursion.
+ """
+ context = dict(memberlist=self, settings=settings)
+ start_date= timezone.localtime(self.date).strftime('%d.%m.%Y')
+ filename = render_tex(f"{self.code}_{self.name}_Krisenliste",
+ 'members/crisis_intervention_list.tex', context,
+ date=self.date, save_only=True)
+ leaders = ", ".join([yl.name for yl in self.jugendleiter.all()])
+ start_date = timezone.localtime(self.date).strftime('%d.%m.%Y')
+ end_date = timezone.localtime(self.end).strftime('%d.%m.%Y')
+ # create email with attachment
+ send_mail(_('Crisis intervention list for %(excursion)s from %(start)s to %(end)s') %\
+ { 'excursion': self.name,
+ 'start': start_date,
+ 'end': end_date },
+ settings.SEND_EXCURSION_CRISIS_LIST.format(excursion=self.name, leaders=leaders,
+ excursion_start=start_date,
+ excursion_end=end_date),
+ sender=settings.DEFAULT_SENDING_MAIL,
+ recipients=[settings.SEKTION_CRISIS_INTERVENTION_MAIL],
+ cc=[settings.RESPONSIBLE_MAIL] + [yl.email for yl in self.jugendleiter.all()],
+ attachments=[media_path(filename)])
+ self.crisis_intervention_list_sent = True
+ self.save()
+
+ def notify_leaders_crisis_intervention_list(self, sending_time=None):
+ """
+ Send an email to the youth leaders of this excursion with a list of currently
+ registered participants and a heads-up that the crisis intervention list
+ will be automatically sent on the night of this day.
+ """
+ participants = "\n".join([f"- {p.member.name}" for p in self.membersonlist.all()])
+ if not sending_time:
+ sending_time = coming_midnight().strftime("%d.%m.%y %H:%M")
+ elif not isinstance(sending_time, str):
+ sending_time = sending_time.strftime("%d.%m.%y %H:%M")
+ start_date = timezone.localtime(self.date).strftime('%d.%m.%Y')
+ end_date = timezone.localtime(self.end).strftime('%d.%m.%Y')
+ excursion_link = prepend_base_url(self.get_absolute_url())
+ for yl in self.jugendleiter.all():
+ yl.send_mail(_('Participant list for %(excursion)s from %(start)s to %(end)s') %\
+ { 'excursion': self.name,
+ 'start': start_date,
+ 'end': end_date },
+ settings.NOTIFY_EXCURSION_PARTICIPANT_LIST.format(name=yl.prename,
+ excursion=self.name,
+ participants=participants,
+ sending_time=sending_time,
+ excursion_link=excursion_link))
+ self.notification_crisis_intervention_list_sent = True
+ self.save()
+
class MemberNoteList(models.Model):
"""
diff --git a/jdav_web/members/tasks.py b/jdav_web/members/tasks.py
index adeb85a..f114396 100644
--- a/jdav_web/members/tasks.py
+++ b/jdav_web/members/tasks.py
@@ -1,7 +1,7 @@
from celery import shared_task
from django.utils import timezone
from django.conf import settings
-from .models import MemberWaitingList
+from .models import MemberWaitingList, Freizeit
@shared_task
def ask_for_waiting_confirmation():
@@ -18,3 +18,31 @@ def ask_for_waiting_confirmation():
waiter.ask_for_wait_confirmation()
no += 1
return no
+
+
+@shared_task
+def send_crisis_intervention_list():
+ """
+ Send crisis intervention lists for all excursions that start on the current day and
+ that have not been sent yet.
+ """
+ no = 0
+ for excursion in Freizeit.objects.filter(date__date=timezone.now().date(),
+ crisis_intervention_list_sent=False):
+ excursion.send_crisis_intervention_list()
+ no += 1
+ return no
+
+
+@shared_task
+def send_notification_crisis_intervention_list():
+ """
+ Send crisis intervention list notifiactions for all excursions that start on the next
+ day and that have not been sent yet.
+ """
+ no = 0
+ for excursion in Freizeit.objects.filter(date__date=timezone.now().date() + timezone.timedelta(days=1),
+ notification_crisis_intervention_list_sent=False):
+ excursion.notify_leaders_crisis_intervention_list()
+ no += 1
+ return no
diff --git a/jdav_web/members/tests.py b/jdav_web/members/tests.py
index 3e69705..e53e662 100644
--- a/jdav_web/members/tests.py
+++ b/jdav_web/members/tests.py
@@ -631,11 +631,20 @@ class MemberAdminTestCase(AdminTestCase):
class FreizeitTestCase(BasicMemberTestCase):
def setUp(self):
super().setUp()
+ # this excursion is used for the counting tests
self.ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=120,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1,
date=timezone.localtime())
+ # this excursion is used in the other tests
+ self.ex2 = Freizeit.objects.create(name='Wild trip 2', kilometers_traveled=120,
+ tour_type=GEMEINSCHAFTS_TOUR,
+ tour_approach=MUSKELKRAFT_ANREISE,
+ difficulty=1,
+ date=timezone.localtime())
+ self.ex2.jugendleiter.add(self.fritz)
+ self.ex2.save()
def _setup_test_sjr_application_numbers(self, n_yl, n_b27_local, n_b27_non_local):
for i in range(n_yl):
@@ -749,6 +758,17 @@ class FreizeitTestCase(BasicMemberTestCase):
for i in range(10):
self._test_sjr_application_numbers(10, 10 - i, i)
+ def test_notify_leaders_crisis_intervention_list(self):
+ self.ex2.notification_crisis_intervention_list_sent = False
+ self.ex2.notify_leaders_crisis_intervention_list()
+ self.assertTrue(self.ex2.notification_crisis_intervention_list_sent)
+ self.ex2.notify_leaders_crisis_intervention_list(sending_time=timezone.now())
+
+ def test_send_crisis_intervention_list(self):
+ self.ex2.crisis_intervention_list_sent = False
+ self.ex2.send_crisis_intervention_list()
+ self.assertTrue(self.ex2.crisis_intervention_list_sent)
+
class PDFActionMixin:
def _test_pdf(self, name, pk, model='freizeit', invalid=False, username='superuser', post_data=None):
diff --git a/jdav_web/utils.py b/jdav_web/utils.py
index 32db40b..129e495 100644
--- a/jdav_web/utils.py
+++ b/jdav_web/utils.py
@@ -1,5 +1,6 @@
from datetime import datetime
from django.db import models
+from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from decimal import Decimal, ROUND_HALF_DOWN
@@ -80,3 +81,10 @@ def normalize_filename(filename, append_date=True, date=None):
filename = filename.replace(' ', '_').replace('&', '').replace('/', '_')
# drop umlauts, accents etc.
return unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
+
+
+def coming_midnight():
+ base = timezone.now() + timezone.timedelta(days=1)
+ return timezone.datetime(year=base.year, month=base.month, day=base.day,
+ hour=0, minute=0, second=0, microsecond=0,
+ tzinfo=base.tzinfo)
From 0eedc3ecf94486a23b299c80d674ac45c75c6a89 Mon Sep 17 00:00:00 2001
From: Christian Merten
Date: Sat, 3 May 2025 19:14:34 +0200
Subject: [PATCH 2/3] feat(finance): send statement summary to finance office
---
jdav_web/finance/admin.py | 5 +++-
.../finance/locale/de/LC_MESSAGES/django.po | 27 +++++++++++++++----
jdav_web/finance/models.py | 27 +++++++++++++++++++
.../templates/admin/confirmed_statement.html | 3 ++-
.../jdav_web/settings/components/texts.py | 8 ++++++
jdav_web/jdav_web/settings/local.py | 1 +
6 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py
index f9a9e2a..d11b233 100644
--- a/jdav_web/finance/admin.py
+++ b/jdav_web/finance/admin.py
@@ -219,7 +219,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
messages.error(request,
_("%(name)s is not yet submitted.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
- if "transaction_execution_confirm" in request.POST:
+ if "transaction_execution_confirm" in request.POST or "transaction_execution_confirm_and_send" in request.POST:
res = statement.confirm(confirmer=get_member(request))
if not res:
# this should NOT happen!
@@ -227,6 +227,9 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
_("An error occured while trying to confirm %(name)s. Please try again.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name)))
+ if "transaction_execution_confirm_and_send" in request.POST:
+ statement.send_summary(cc=[request.user.member.email] if hasattr(request.user, 'member') else [])
+ messages.success(request, _("Successfully sent receipt to the office."))
messages.success(request,
_("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.")
% {'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 df726c6..24fed2d 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-04-27 23:00+0200\n"
+"POT-Creation-Date: 2025-05-03 19:06+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -64,6 +64,10 @@ msgstr ""
"Beim Abwickeln von %(name)s ist ein Fehler aufgetreten. Bitte versuche es "
"erneut."
+#: finance/admin.py
+msgid "Successfully sent receipt to the office."
+msgstr "Abrechnungsbeleg an die Geschäftsstelle gesendet."
+
#: finance/admin.py
#, python-format
msgid ""
@@ -283,6 +287,10 @@ msgstr "Abrechnungen"
msgid "Statement: %(excursion)s"
msgstr "Abrechnung: %(excursion)s"
+#: finance/models.py
+msgid "Excursion %(excursion)s"
+msgstr "Ausfahrt %(excursion)s"
+
#: finance/models.py
msgid "Ready to confirm"
msgstr "Bereit zur Abwicklung"
@@ -310,6 +318,11 @@ msgstr "LJP-Zuschuss %(excu)s"
msgid "Total"
msgstr "Gesamtbetrag"
+#: finance/models.py
+#, python-format
+msgid "Statement summary for %(title)s"
+msgstr "Abrechnung für %(title)s"
+
#: finance/models.py
msgid "Statement in preparation"
msgstr "Abrechnung in Vorbereitung"
@@ -433,8 +446,12 @@ msgid "I did execute the listed transactions."
msgstr "Ich habe die aufgeführten Überweisungen ausgeführt."
#: finance/templates/admin/confirmed_statement.html
-msgid "Confirm"
-msgstr "Bestätigen"
+msgid "Confirm only"
+msgstr "Nur bestätigen"
+
+#: finance/templates/admin/confirmed_statement.html
+msgid "Confirm and send receipt to office"
+msgstr "Bestätigen und Beleg an die Geschäftsstelle senden"
#: finance/templates/admin/overview_submitted_statement.html
msgid "Overview"
@@ -558,8 +575,8 @@ msgid ""
"against allowances and subsidies."
msgstr ""
"Da Personen über 27 an der Ausfahrt teilnehommen haben, wird ein "
-"Organisationsbeitrag von %(org_fee)s€ pro Person und Tag fällig. Der Gesamtbetrag "
-"von %(total_org_fee_theoretical)s€ wird mit Zuschüssen und "
+"Organisationsbeitrag von %(org_fee)s€ pro Person und Tag fällig. Der "
+"Gesamtbetrag von %(total_org_fee_theoretical)s€ wird mit Zuschüssen und "
"Aufwandsentschädigungen verrechnet."
#: finance/templates/admin/overview_submitted_statement.html
diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py
index 2e4b3e1..c09cf22 100644
--- a/jdav_web/finance/models.py
+++ b/jdav_web/finance/models.py
@@ -15,6 +15,9 @@ import rules
from contrib.models import CommonModel
from contrib.rules import has_global_perm
from utils import cvt_to_decimal, RestrictedFileField
+from members.pdf import render_tex_with_attachments
+from mailer.mailutils import send as send_mail
+from contrib.media import media_path
from schwifty import IBAN
import re
@@ -121,6 +124,13 @@ class Statement(CommonModel):
else:
return self.short_description
+ @property
+ def title(self):
+ if self.excursion is not None:
+ return _('Excursion %(excursion)s') % {'excursion': str(self.excursion)}
+ else:
+ return self.short_description
+
def submit(self, submitter=None):
self.submitted = True
self.submitted_date = timezone.now()
@@ -534,6 +544,23 @@ class Statement(CommonModel):
.order_by('short_description')\
.annotate(amount=Sum('amount'))
+ def send_summary(self, cc=None):
+ """
+ Sends a summary of the statement to the central office of the association.
+ """
+ excursion = self.excursion
+ context = dict(statement=self.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 self.bills_covered if bill.proof]
+ filename = render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex',
+ context, attachments, save_only=True)
+ send_mail(_('Statement summary for %(title)s') % { 'title': self.title },
+ settings.SEND_STATEMENT_SUMMARY.format(statement=self.title),
+ sender=settings.DEFAULT_SENDING_MAIL,
+ recipients=[settings.SEKTION_FINANCE_MAIL],
+ cc=cc,
+ attachments=[media_path(filename)])
+
class StatementUnSubmittedManager(models.Manager):
def get_queryset(self):
diff --git a/jdav_web/finance/templates/admin/confirmed_statement.html b/jdav_web/finance/templates/admin/confirmed_statement.html
index 3d35418..23e0c2c 100644
--- a/jdav_web/finance/templates/admin/confirmed_statement.html
+++ b/jdav_web/finance/templates/admin/confirmed_statement.html
@@ -110,6 +110,7 @@ links.forEach(link => {
{% blocktrans %}I did execute the listed transactions.{% endblocktrans %}
-
+
+
{% endblock %}
diff --git a/jdav_web/jdav_web/settings/components/texts.py b/jdav_web/jdav_web/settings/components/texts.py
index e70e3c5..43bc197 100644
--- a/jdav_web/jdav_web/settings/components/texts.py
+++ b/jdav_web/jdav_web/settings/components/texts.py
@@ -273,3 +273,11 @@ Im Anhang findet ihr die Kriseninterventionsliste.
Viele Grüße
Euer KOMPASS""")
+
+SEND_STATEMENT_SUMMARY = get_text('send_statement_summary', default="""Hallo zusammen,
+
+anbei findet ihr die Abrechnung inklusive Belege für {statement}. Die Überweisungen
+wurden wie beschrieben ausgeführt.
+
+Viele Grüße
+Euer KOMPASS""")
diff --git a/jdav_web/jdav_web/settings/local.py b/jdav_web/jdav_web/settings/local.py
index 34dbe5c..71e260c 100644
--- a/jdav_web/jdav_web/settings/local.py
+++ b/jdav_web/jdav_web/settings/local.py
@@ -9,6 +9,7 @@ SEKTION_CONTACT_MAIL = get_var('section', 'contact_mail', default='info@example.
SEKTION_BOARD_MAIL = get_var('section', 'board_mail', default=SEKTION_CONTACT_MAIL)
SEKTION_CRISIS_INTERVENTION_MAIL = get_var('section', 'crisis_intervention_mail',
default=SEKTION_BOARD_MAIL)
+SEKTION_FINANCE_MAIL = get_var('section', 'finance_mail', default=SEKTION_CONTACT_MAIL)
SEKTION_IBAN = get_var('section', 'iban', default='Foo 123')
SEKTION_ACCOUNT_HOLDER = get_var('section', 'account_holder',
default='Foo')
From 7647f93c83a307e6bbe7fb9e00894dcfddd84d90 Mon Sep 17 00:00:00 2001
From: Christian Merten
Date: Sat, 3 May 2025 20:26:05 +0200
Subject: [PATCH 3/3] chore(members): move csv import to separate file
---
jdav_web/members/csv.py | 227 +++++++++++++++++++++++++++++++++++++
jdav_web/members/models.py | 227 +------------------------------------
2 files changed, 228 insertions(+), 226 deletions(-)
create mode 100644 jdav_web/members/csv.py
diff --git a/jdav_web/members/csv.py b/jdav_web/members/csv.py
new file mode 100644
index 0000000..3f50377
--- /dev/null
+++ b/jdav_web/members/csv.py
@@ -0,0 +1,227 @@
+from .models import *
+import re
+import csv
+
+
+def import_from_csv(path, omit_groupless=True):
+ with open(path, encoding='ISO-8859-1') as csvfile:
+ reader = csv.DictReader(csvfile, delimiter=';')
+ rows = list(reader)
+
+ def transform_field(key, value):
+ new_key = CLUBDESK_TO_KOMPASS[key]
+ if isinstance(new_key, str):
+ return (new_key, value)
+ else:
+ return (new_key[0], new_key[1](value))
+
+ def transform_row(row):
+ kwargs = dict([ transform_field(k, v) for k, v in row.items() if k in CLUBDESK_TO_KOMPASS ])
+ kwargs_filtered = { k : v for k, v in kwargs.items() if k not in ['group', 'last_training', 'has_fundamental_training', 'special_training', 'phone_number_private', 'phone_number_parents'] }
+ if not kwargs['group'] and omit_groupless:
+ # if member does not have a group, skip them
+ return
+ mem = Member(**kwargs_filtered)
+ mem.save()
+ mem.group.set([group for group, is_jl in kwargs['group']])
+ for group, is_jl in kwargs['group']:
+ if is_jl:
+ group.leiters.add(mem)
+
+ if kwargs['has_fundamental_training']:
+ try:
+ ga_cat = TrainingCategory.objects.get(name='Grundausbildung')
+ except TrainingCategory.DoesNotExist:
+ ga_cat = TrainingCategory(name='Grundausbildung', permission_needed=True)
+ ga_cat.save()
+ ga_training = MemberTraining(member=mem, title='Grundausbildung', date=None, category=ga_cat,
+ participated=True, passed=True)
+ ga_training.save()
+
+ if kwargs['last_training'] is not None:
+ try:
+ cat = TrainingCategory.objects.get(name='Fortbildung')
+ except TrainingCategory.DoesNotExist:
+ cat = TrainingCategory(name='Fortbildung', permission_needed=False)
+ cat.save()
+ training = MemberTraining(member=mem, title='Unbekannt', date=kwargs['last_training'], category=cat,
+ participated=True, passed=True)
+ training.save()
+
+ if kwargs['special_training'] != '':
+ try:
+ cat = TrainingCategory.objects.get(name='Sonstiges')
+ except TrainingCategory.DoesNotExist:
+ cat = TrainingCategory(name='Sonstiges', permission_needed=False)
+ cat.save()
+ training = MemberTraining(member=mem, title=kwargs['special_training'], date=None, category=cat,
+ participated=True, passed=True)
+ training.save()
+
+ if kwargs['phone_number_private'] != '':
+ prefix = '\n' if mem.comments else ''
+ mem.comments += prefix + 'Telefon (Privat): ' + kwargs['phone_number_private']
+ mem.save()
+
+ if kwargs['phone_number_parents'] != '':
+ prefix = '\n' if mem.comments else ''
+ mem.comments += prefix + 'Telefon (Eltern): ' + kwargs['phone_number_parents']
+ mem.save()
+
+ for row in rows:
+ transform_row(row)
+
+
+def parse_group(value):
+ groups_raw = re.split(',', value)
+
+ # need to determine if member is youth leader
+ roles = set()
+ def extract_group_name_and_role(raw):
+ obj = re.search('^(.*?)(?: \((.*)\))?$', raw)
+ is_jl = False
+ if obj.group(2) is not None:
+ roles.add(obj.group(2).strip())
+ if obj.group(2) == 'Jugendleiter*in':
+ is_jl = True
+ return (obj.group(1).strip(), is_jl)
+
+ group_names = [extract_group_name_and_role(raw) for raw in groups_raw if raw != '']
+
+ if "Jugendleiter*in" in roles:
+ group_names.append(('Jugendleiter', False))
+ groups = []
+ for group_name, is_jl in group_names:
+ try:
+ group = Group.objects.get(name=group_name)
+ except Group.DoesNotExist:
+ group = Group(name=group_name)
+ group.save()
+ groups.append((group, is_jl))
+ return groups
+
+
+def parse_date(value):
+ if value == '':
+ return None
+ return datetime.strptime(value, '%d.%m.%Y').date()
+
+
+def parse_datetime(value):
+ tz = pytz.timezone('Europe/Berlin')
+ if value == '':
+ return timezone.now()
+ return tz.localize(datetime.strptime(value, '%d.%m.%Y %H:%M:%S'))
+
+
+def parse_status(value):
+ return value != "Passivmitglied"
+
+
+def parse_boolean(value):
+ return value.lower() == "ja"
+
+
+def parse_nullable_boolean(value):
+ if value == '':
+ return None
+ else:
+ return value.lower() == "ja"
+
+
+def parse_gender(value):
+ if value == 'männlich':
+ return MALE
+ elif value == 'weiblich':
+ return FEMALE
+ else:
+ return DIVERSE
+
+
+def parse_can_swim(value):
+ return True if len(value) > 0 else False
+
+
+CLUBDESK_TO_KOMPASS = {
+ 'Nachname': 'lastname',
+ 'Vorname': 'prename',
+ 'Adresse': 'street',
+ 'PLZ': 'plz',
+ 'Ort': 'town',
+ 'Telefon Privat': 'phone_number_private',
+ 'Telefon Mobil': 'phone_number',
+ 'Adress-Zusatz': 'address_extra',
+ 'Land': 'country',
+ 'E-Mail': 'email',
+ 'E-Mail Alternativ': 'alternative_email',
+ 'Status': ('active', parse_status),
+ 'Eintritt': ('join_date', parse_date),
+ 'Austritt': ('leave_date', parse_date),
+ 'Geburtsdatum': ('birth_date', parse_date),
+ 'Geburtstag': ('birth_date', parse_date),
+ 'Geschlecht': ('gender', parse_gender),
+ 'Bemerkungen': 'comments',
+ 'IBAN': 'iban',
+ 'Vorlage Führungszeugnis': ('good_conduct_certificate_presented_date', parse_date),
+ 'Letzte Fortbildung': ('last_training', parse_date),
+ 'Grundausbildung': ('has_fundamental_training', parse_boolean),
+ 'Besondere Ausbildung': 'special_training',
+ '[Gruppen]' : ('group', parse_group),
+ 'Schlüssel': ('has_key', parse_boolean),
+ 'Freikarte': ('has_free_ticket_gym', parse_boolean),
+ 'DAV Ausweis Nr.': 'dav_badge_no',
+ 'Schwimmabzeichen': ('swimming_badge', parse_can_swim),
+ 'Kletterschein': 'climbing_badge',
+ 'Felserfahrung': 'alpine_experience',
+ 'Allergien': 'allergies',
+ 'Medikamente': 'medication',
+ 'Tetanusimpfung': 'tetanus_vaccination',
+ 'Fotoerlaubnis': ('photos_may_be_taken', parse_boolean),
+ 'Erziehungsberechtigte': 'legal_guardians',
+ 'Darf sich allein von der Gruppenstunde abmelden':
+ ('may_cancel_appointment_independently', parse_nullable_boolean),
+ 'Mobil Eltern': 'phone_number_parents',
+ 'Sonstiges': 'application_text',
+ 'Erhalten am': ('application_date', parse_datetime),
+ 'Angeschrieben von': 'contacted_by',
+ 'Angeschrieben von ': 'contacted_by',
+}
+
+
+def import_from_csv_waitinglist(path):
+ with open(path, encoding='ISO-8859-1') as csvfile:
+ reader = csv.DictReader(csvfile, delimiter=';')
+ rows = list(reader)
+
+ def transform_field(key, value):
+ new_key = CLUBDESK_TO_KOMPASS[key]
+ if isinstance(new_key, str):
+ return (new_key, value)
+ else:
+ return (new_key[0], new_key[1](value))
+
+ def transform_field(key, value):
+ new_key = CLUBDESK_TO_KOMPASS[key]
+ if isinstance(new_key, str):
+ return (new_key, value)
+ else:
+ return (new_key[0], new_key[1](value))
+
+ def transform_row(row):
+ kwargs = dict([ transform_field(k, v) for k, v in row.items() if k in CLUBDESK_TO_KOMPASS ])
+ kwargs_filtered = { k : v for k, v in kwargs.items() if k in ['prename', 'lastname', 'email', 'birth_date', 'application_text', 'application_date'] }
+
+ mem = MemberWaitingList(gender=DIVERSE, **kwargs_filtered)
+ mem.save()
+
+ if kwargs['contacted_by']:
+ group_name = kwargs['contacted_by']
+ try:
+ group = Group.objects.get(name=group_name)
+ invitation = InvitationToGroup(group=group, waiter=mem)
+ invitation.save()
+ except Group.DoesNotExist:
+ pass
+
+ for row in rows:
+ transform_row(row)
diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py
index 2e84423..1700f2b 100644
--- a/jdav_web/members/models.py
+++ b/jdav_web/members/models.py
@@ -4,7 +4,6 @@ import math
import pytz
import unicodedata
import re
-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
@@ -2015,7 +2014,7 @@ class MemberTraining(CommonModel):
'image/jpeg',
'image/png',
'image/gif'])
-
+
class Meta(CommonModel.Meta):
verbose_name = _('Training')
verbose_name_plural = _('Trainings')
@@ -2026,227 +2025,3 @@ class MemberTraining(CommonModel):
'change_obj': is_oneself | has_global_perm('members.change_global_membertraining'),
'delete_obj': is_oneself | has_global_perm('members.delete_global_membertraining'),
}
-
-
-def import_from_csv(path, omit_groupless=True):
- with open(path, encoding='ISO-8859-1') as csvfile:
- reader = csv.DictReader(csvfile, delimiter=';')
- rows = list(reader)
-
- def transform_field(key, value):
- new_key = CLUBDESK_TO_KOMPASS[key]
- if isinstance(new_key, str):
- return (new_key, value)
- else:
- return (new_key[0], new_key[1](value))
-
- def transform_row(row):
- kwargs = dict([ transform_field(k, v) for k, v in row.items() if k in CLUBDESK_TO_KOMPASS ])
- kwargs_filtered = { k : v for k, v in kwargs.items() if k not in ['group', 'last_training', 'has_fundamental_training', 'special_training', 'phone_number_private', 'phone_number_parents'] }
- if not kwargs['group'] and omit_groupless:
- # if member does not have a group, skip them
- return
- mem = Member(**kwargs_filtered)
- mem.save()
- mem.group.set([group for group, is_jl in kwargs['group']])
- for group, is_jl in kwargs['group']:
- if is_jl:
- group.leiters.add(mem)
-
- if kwargs['has_fundamental_training']:
- try:
- ga_cat = TrainingCategory.objects.get(name='Grundausbildung')
- except TrainingCategory.DoesNotExist:
- ga_cat = TrainingCategory(name='Grundausbildung', permission_needed=True)
- ga_cat.save()
- ga_training = MemberTraining(member=mem, title='Grundausbildung', date=None, category=ga_cat,
- participated=True, passed=True)
- ga_training.save()
-
- if kwargs['last_training'] is not None:
- try:
- cat = TrainingCategory.objects.get(name='Fortbildung')
- except TrainingCategory.DoesNotExist:
- cat = TrainingCategory(name='Fortbildung', permission_needed=False)
- cat.save()
- training = MemberTraining(member=mem, title='Unbekannt', date=kwargs['last_training'], category=cat,
- participated=True, passed=True)
- training.save()
-
- if kwargs['special_training'] != '':
- try:
- cat = TrainingCategory.objects.get(name='Sonstiges')
- except TrainingCategory.DoesNotExist:
- cat = TrainingCategory(name='Sonstiges', permission_needed=False)
- cat.save()
- training = MemberTraining(member=mem, title=kwargs['special_training'], date=None, category=cat,
- participated=True, passed=True)
- training.save()
-
- if kwargs['phone_number_private'] != '':
- prefix = '\n' if mem.comments else ''
- mem.comments += prefix + 'Telefon (Privat): ' + kwargs['phone_number_private']
- mem.save()
-
- if kwargs['phone_number_parents'] != '':
- prefix = '\n' if mem.comments else ''
- mem.comments += prefix + 'Telefon (Eltern): ' + kwargs['phone_number_parents']
- mem.save()
-
- for row in rows:
- transform_row(row)
-
-
-def parse_group(value):
- groups_raw = re.split(',', value)
-
- # need to determine if member is youth leader
- roles = set()
- def extract_group_name_and_role(raw):
- obj = re.search('^(.*?)(?: \((.*)\))?$', raw)
- is_jl = False
- if obj.group(2) is not None:
- roles.add(obj.group(2).strip())
- if obj.group(2) == 'Jugendleiter*in':
- is_jl = True
- return (obj.group(1).strip(), is_jl)
-
- group_names = [extract_group_name_and_role(raw) for raw in groups_raw if raw != '']
-
- if "Jugendleiter*in" in roles:
- group_names.append(('Jugendleiter', False))
- groups = []
- for group_name, is_jl in group_names:
- try:
- group = Group.objects.get(name=group_name)
- except Group.DoesNotExist:
- group = Group(name=group_name)
- group.save()
- groups.append((group, is_jl))
- return groups
-
-
-def parse_date(value):
- if value == '':
- return None
- return datetime.strptime(value, '%d.%m.%Y').date()
-
-
-def parse_datetime(value):
- tz = pytz.timezone('Europe/Berlin')
- if value == '':
- return timezone.now()
- return tz.localize(datetime.strptime(value, '%d.%m.%Y %H:%M:%S'))
-
-
-def parse_status(value):
- return value != "Passivmitglied"
-
-
-def parse_boolean(value):
- return value.lower() == "ja"
-
-
-def parse_nullable_boolean(value):
- if value == '':
- return None
- else:
- return value.lower() == "ja"
-
-
-def parse_gender(value):
- if value == 'männlich':
- return MALE
- elif value == 'weiblich':
- return FEMALE
- else:
- return DIVERSE
-
-
-def parse_can_swim(value):
- return True if len(value) > 0 else False
-
-
-CLUBDESK_TO_KOMPASS = {
- 'Nachname': 'lastname',
- 'Vorname': 'prename',
- 'Adresse': 'street',
- 'PLZ': 'plz',
- 'Ort': 'town',
- 'Telefon Privat': 'phone_number_private',
- 'Telefon Mobil': 'phone_number',
- 'Adress-Zusatz': 'address_extra',
- 'Land': 'country',
- 'E-Mail': 'email',
- 'E-Mail Alternativ': 'alternative_email',
- 'Status': ('active', parse_status),
- 'Eintritt': ('join_date', parse_date),
- 'Austritt': ('leave_date', parse_date),
- 'Geburtsdatum': ('birth_date', parse_date),
- 'Geburtstag': ('birth_date', parse_date),
- 'Geschlecht': ('gender', parse_gender),
- 'Bemerkungen': 'comments',
- 'IBAN': 'iban',
- 'Vorlage Führungszeugnis': ('good_conduct_certificate_presented_date', parse_date),
- 'Letzte Fortbildung': ('last_training', parse_date),
- 'Grundausbildung': ('has_fundamental_training', parse_boolean),
- 'Besondere Ausbildung': 'special_training',
- '[Gruppen]' : ('group', parse_group),
- 'Schlüssel': ('has_key', parse_boolean),
- 'Freikarte': ('has_free_ticket_gym', parse_boolean),
- 'DAV Ausweis Nr.': 'dav_badge_no',
- 'Schwimmabzeichen': ('swimming_badge', parse_can_swim),
- 'Kletterschein': 'climbing_badge',
- 'Felserfahrung': 'alpine_experience',
- 'Allergien': 'allergies',
- 'Medikamente': 'medication',
- 'Tetanusimpfung': 'tetanus_vaccination',
- 'Fotoerlaubnis': ('photos_may_be_taken', parse_boolean),
- 'Erziehungsberechtigte': 'legal_guardians',
- 'Darf sich allein von der Gruppenstunde abmelden':
- ('may_cancel_appointment_independently', parse_nullable_boolean),
- 'Mobil Eltern': 'phone_number_parents',
- 'Sonstiges': 'application_text',
- 'Erhalten am': ('application_date', parse_datetime),
- 'Angeschrieben von': 'contacted_by',
- 'Angeschrieben von ': 'contacted_by',
-}
-
-
-def import_from_csv_waitinglist(path):
- with open(path, encoding='ISO-8859-1') as csvfile:
- reader = csv.DictReader(csvfile, delimiter=';')
- rows = list(reader)
-
- def transform_field(key, value):
- new_key = CLUBDESK_TO_KOMPASS[key]
- if isinstance(new_key, str):
- return (new_key, value)
- else:
- return (new_key[0], new_key[1](value))
-
- def transform_field(key, value):
- new_key = CLUBDESK_TO_KOMPASS[key]
- if isinstance(new_key, str):
- return (new_key, value)
- else:
- return (new_key[0], new_key[1](value))
-
- def transform_row(row):
- kwargs = dict([ transform_field(k, v) for k, v in row.items() if k in CLUBDESK_TO_KOMPASS ])
- kwargs_filtered = { k : v for k, v in kwargs.items() if k in ['prename', 'lastname', 'email', 'birth_date', 'application_text', 'application_date'] }
-
- mem = MemberWaitingList(gender=DIVERSE, **kwargs_filtered)
- mem.save()
-
- if kwargs['contacted_by']:
- group_name = kwargs['contacted_by']
- try:
- group = Group.objects.get(name=group_name)
- invitation = InvitationToGroup(group=group, waiter=mem)
- invitation.save()
- except Group.DoesNotExist:
- pass
-
- for row in rows:
- transform_row(row)