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 852429b..b9c8a7a 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() @@ -539,6 +549,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 69743dc..43bc197 100644 --- a/jdav_web/jdav_web/settings/components/texts.py +++ b/jdav_web/jdav_web/settings/components/texts.py @@ -247,3 +247,37 @@ 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""") + +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') 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/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 8867940..26cc7af 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 @@ -26,10 +25,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 +1218,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 +1350,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 @@ -1616,6 +1621,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): """ @@ -1961,7 +2021,7 @@ class MemberTraining(CommonModel): 'image/jpeg', 'image/png', 'image/gif']) - + class Meta(CommonModel.Meta): verbose_name = _('Training') verbose_name_plural = _('Trainings') @@ -1972,227 +2032,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) 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)