From 1ada10fda47a5d5db8595c6d5b13df812f936cee Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 3 May 2025 18:29:13 +0200 Subject: [PATCH] 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)