From 1ada10fda47a5d5db8595c6d5b13df812f936cee Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 3 May 2025 18:29:13 +0200 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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) From 07bf5ff53fef88e948a3a6af78e777ae88e2b64d Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 4 May 2025 18:10:34 +0200 Subject: [PATCH 04/16] chore(members): add more tests --- docker/test/config/settings.toml | 3 + jdav_web/.coveragerc | 4 + jdav_web/members/csv.py | 22 +-- jdav_web/members/tests.py | 265 ++++++++++++++++++++++++++++++- 4 files changed, 276 insertions(+), 18 deletions(-) diff --git a/docker/test/config/settings.toml b/docker/test/config/settings.toml index f1e5f79..08414ab 100644 --- a/docker/test/config/settings.toml +++ b/docker/test/config/settings.toml @@ -29,3 +29,6 @@ password = 'password' [startpage] recent_section = 'aktuelles' reports_section = 'berichte' + +[misc] +allowed_email_domains_for_invite_as_user = ['test-organization.org'] diff --git a/jdav_web/.coveragerc b/jdav_web/.coveragerc index ef96063..46d095d 100644 --- a/jdav_web/.coveragerc +++ b/jdav_web/.coveragerc @@ -1,3 +1,7 @@ +[run] + source = + . + [report] omit = ./jet/* diff --git a/jdav_web/members/csv.py b/jdav_web/members/csv.py index 3f50377..3fec6c8 100644 --- a/jdav_web/members/csv.py +++ b/jdav_web/members/csv.py @@ -3,7 +3,7 @@ import re import csv -def import_from_csv(path, omit_groupless=True): +def import_from_csv(path, omit_groupless=True): # pragma: no cover with open(path, encoding='ISO-8859-1') as csvfile: reader = csv.DictReader(csvfile, delimiter=';') rows = list(reader) @@ -72,7 +72,7 @@ def import_from_csv(path, omit_groupless=True): transform_row(row) -def parse_group(value): +def parse_group(value): # pragma: no cover groups_raw = re.split(',', value) # need to determine if member is youth leader @@ -101,35 +101,35 @@ def parse_group(value): return groups -def parse_date(value): +def parse_date(value): # pragma: no cover if value == '': return None return datetime.strptime(value, '%d.%m.%Y').date() -def parse_datetime(value): +def parse_datetime(value): # pragma: no cover 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): +def parse_status(value): # pragma: no cover return value != "Passivmitglied" -def parse_boolean(value): +def parse_boolean(value): # pragma: no cover return value.lower() == "ja" -def parse_nullable_boolean(value): +def parse_nullable_boolean(value): # pragma: no cover if value == '': return None else: return value.lower() == "ja" -def parse_gender(value): +def parse_gender(value): # pragma: no cover if value == 'männlich': return MALE elif value == 'weiblich': @@ -138,11 +138,11 @@ def parse_gender(value): return DIVERSE -def parse_can_swim(value): +def parse_can_swim(value): # pragma: no cover return True if len(value) > 0 else False -CLUBDESK_TO_KOMPASS = { +CLUBDESK_TO_KOMPASS = { # pragma: no cover 'Nachname': 'lastname', 'Vorname': 'prename', 'Adresse': 'street', @@ -188,7 +188,7 @@ CLUBDESK_TO_KOMPASS = { } -def import_from_csv_waitinglist(path): +def import_from_csv_waitinglist(path): # pragma: no cover with open(path, encoding='ISO-8859-1') as csvfile: reader = csv.DictReader(csvfile, delimiter=';') rows = list(reader) diff --git a/jdav_web/members/tests.py b/jdav_web/members/tests.py index e53e662..4e59f15 100644 --- a/jdav_web/members/tests.py +++ b/jdav_web/members/tests.py @@ -14,10 +14,13 @@ from django.conf import settings from django.urls import reverse from django import template from unittest import skip, mock -from .models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE,\ +from .models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR,\ + MUSKELKRAFT_ANREISE, FUEHRUNGS_TOUR, AUSBILDUNGS_TOUR, OEFFENTLICHE_ANREISE,\ + FAHRGEMEINSCHAFT_ANREISE,\ MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\ RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup, DIVERSE, MALE, FEMALE,\ - Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS + Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\ + TrainingCategory, Person from .admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\ MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\ MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin @@ -34,6 +37,7 @@ import math import os.path +INTERNAL_EMAIL = "foobar@{domain}".format(domain=settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]) REGISTRATION_DATA = { 'prename': 'Peter', 'lastname': 'Wulter', @@ -109,7 +113,7 @@ class BasicMemberTestCase(TestCase): self.peter.save() self.lara = Member.objects.create(prename="Lara", lastname="Wallis", birth_date=timezone.now().date(), - email=settings.TEST_MAIL, gender=DIVERSE) + email=INTERNAL_EMAIL, gender=DIVERSE) self.lara.group.add(self.alp) self.lara.save() self.fridolin = Member.objects.create(prename="Fridolin", lastname="Spargel", birth_date=timezone.now().date(), @@ -120,6 +124,8 @@ class BasicMemberTestCase(TestCase): self.lise = Member.objects.create(prename="Lise", lastname="Lotte", birth_date=timezone.now().date(), email=settings.TEST_MAIL, gender=FEMALE) + self.alp.leiters.add(self.lise) + self.alp.save() class MemberTestCase(BasicMemberTestCase): @@ -262,6 +268,41 @@ class MemberTestCase(BasicMemberTestCase): def test_association_email(self): self.assertIn(settings.DOMAIN, self.peter.association_email) + def test_registration_complete(self): + # this is currently a dummy that always returns True + self.assertTrue(self.peter.registration_complete()) + + def test_unconfirm(self): + self.assertTrue(self.peter.confirmed) + self.peter.unconfirm() + self.assertFalse(self.peter.confirmed) + + def test_generate_upload_registration_form_key(self): + self.peter.generate_upload_registration_form_key() + self.assertIsNotNone(self.peter.upload_registration_form_key) + + def test_has_internal_email(self): + self.peter.email = 'foobar' + self.assertFalse(self.peter.has_internal_email()) + + def test_invite_as_user(self): + self.assertTrue(self.lara.has_internal_email()) + self.lara.user = None + self.assertTrue(self.lara.invite_as_user()) + u = User.objects.create_user(username='user', password='secret', is_staff=True) + self.peter.user = u + self.assertFalse(self.peter.invite_as_user()) + + def test_birth_date_str(self): + self.fritz.birth_date = None + self.assertEqual(self.fritz.birth_date_str, '---') + date = timezone.now().date() + self.fritz.birth_date = date + self.assertEqual(self.fritz.birth_date_str, date.strftime('%d.%m.%Y')) + + def test_gender_str(self): + self.assertGreater(len(self.fritz.gender_str), 0) + class PDFTestCase(TestCase): def setUp(self): @@ -270,8 +311,12 @@ class PDFTestCase(TestCase): tour_approach=MUSKELKRAFT_ANREISE, difficulty=1) self.note = MemberNoteList.objects.create(title='Coolß! ‬löst') + self.cat = ActivityCategory.objects.create(name='Climbing', description='Climbing') + ActivityCategory.objects.create(name='Walking', description='Climbing') + self.ex.activity.add(self.cat) + self.ex.save() - for i in range(7): + for i in range(15): m = Member.objects.create(prename='Liääüuße {}'.format(i), lastname='Walter&co ‬: _ kg &', birth_date=timezone.now().date(), @@ -550,7 +595,7 @@ class MemberAdminTestCase(AdminTestCase): _("The configured email address for %(name)s is not an internal one.") % {'name': str(self.fritz)}) # update email to allowed email domain - self.fritz.email = 'foobar@{domain}'.format(domain=settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]) + self.fritz.email = INTERNAL_EMAIL self.fritz.save() response = c.post(url) # expect: user is found and confirmation page is shown @@ -594,9 +639,9 @@ class MemberAdminTestCase(AdminTestCase): self.assertContains(response, _('Some members have been invited, others could not be invited.')) # confirm invite, expect: success - self.peter.email = 'foobar@{domain}'.format(domain=settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]) + self.peter.email = INTERNAL_EMAIL self.peter.save() - self.fritz.email = 'foobar@{domain}'.format(domain=settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]) + self.fritz.email = INTERNAL_EMAIL self.fritz.save() response = c.post(url, data={'action': 'invite_as_user_action', '_selected_action': [self.fritz.pk, self.peter.pk], 'apply': True}, follow=True) @@ -644,6 +689,7 @@ class FreizeitTestCase(BasicMemberTestCase): difficulty=1, date=timezone.localtime()) self.ex2.jugendleiter.add(self.fritz) + self.st = Statement.objects.create(excursion=self.ex2, night_cost=42, subsidy_to=None) self.ex2.save() def _setup_test_sjr_application_numbers(self, n_yl, n_b27_local, n_b27_non_local): @@ -769,6 +815,56 @@ class FreizeitTestCase(BasicMemberTestCase): self.ex2.send_crisis_intervention_list() self.assertTrue(self.ex2.crisis_intervention_list_sent) + def test_filter_queryset_by_permissions(self): + qs = Freizeit.filter_queryset_by_permissions(self.fritz) + self.assertIn(self.ex2, qs) + + def test_v32_fields(self): + self.assertIn('Textfeld 61', self.ex2.v32_fields().keys()) + + @skip("This currently throws a `RelatedObjectDoesNotExist` error.") + def test_no_statement(self): + self.assertEqual(self.ex.total_relative_costs, 0) + self.assertEqual(self.ex.payable_ljp_contributions, 0) + + def test_no_ljpproposal(self): + self.assertEqual(self.ex2.total_intervention_hours, 0) + self.assertEqual(self.ex2.seminar_time_per_day, []) + + def test_relative_costs(self): + # after deducting contributions, the total costs should still be non-negative + self.assertGreaterEqual(self.ex2.total_relative_costs, 0) + + def test_payable_ljp_contributions(self): + self.assertGreaterEqual(self.ex2.payable_ljp_contributions, 0) + + def test_get_tour_type(self): + self.ex2.tour_type = GEMEINSCHAFTS_TOUR + self.assertEqual(self.ex2.get_tour_type(), 'Gemeinschaftstour') + self.ex2.tour_type = FUEHRUNGS_TOUR + self.assertEqual(self.ex2.get_tour_type(), 'Führungstour') + self.ex2.tour_type = AUSBILDUNGS_TOUR + self.assertEqual(self.ex2.get_tour_type(), 'Ausbildung') + + def test_get_tour_approach(self): + self.ex2.tour_approach = MUSKELKRAFT_ANREISE + self.assertEqual(self.ex2.get_tour_approach(), 'Muskelkraft') + self.ex2.tour_approach = OEFFENTLICHE_ANREISE + self.assertEqual(self.ex2.get_tour_approach(), 'ÖPNV') + self.ex2.tour_approach = FAHRGEMEINSCHAFT_ANREISE + self.assertEqual(self.ex2.get_tour_approach(), 'Fahrgemeinschaften') + + def test_duration(self): + self.assertGreaterEqual(self.ex.duration, 0) + self.ex.date = timezone.datetime(2000, 1, 1, 8, 0, 0) + self.ex.end = timezone.datetime(2000, 1, 1, 10, 0, 0) + self.assertEqual(self.ex.duration, 0.5) + + # TODO: fix this in the model, the duration of this excursion should be 0 + self.ex.date = timezone.datetime(2000, 1, 1, 12, 0, 0) + self.ex.end = timezone.datetime(2000, 1, 1, 12, 0, 0) + self.assertEqual(self.ex.duration, 1) + class PDFActionMixin: def _test_pdf(self, name, pk, model='freizeit', invalid=False, username='superuser', post_data=None): @@ -1024,6 +1120,9 @@ class MemberNoteListAdminTestCase(AdminTestCase, PDFActionMixin): email=settings.TEST_MAIL, gender=FEMALE) NewMemberOnList.objects.create(member=m, comments='a' * i, memberlist=self.note) + def test_str(self): + self.assertEqual(str(self.note), 'Cool list') + def test_membernote_summary(self): self._test_pdf('summary', self.note.pk, model='membernotelist') self._test_pdf('summary', self.note.pk, model='membernotelist', username='standard', invalid=True) @@ -1197,6 +1296,24 @@ class MailConfirmationTestCase(BasicMemberTestCase): self.father = EmergencyContact.objects.create(prename='Olaf', lastname='Old', email=settings.TEST_MAIL, member=self.fritz) self.father.save() + self.reg = MemberUnconfirmedProxy.objects.create(**REGISTRATION_DATA, confirmed=False) + self.reg.group.add(self.alp) + file = SimpleUploadedFile("form.pdf", b"file_content", content_type="application/pdf") + self.reg.registration_form = file + self.reg.save() + + def test_request_mail_confirmation(self): + self.reg.confirmed_mail = True + self.reg.confirmed_alternative_mail = True + self.assertFalse(self.reg.request_mail_confirmation(rerequest=False)) + + def test_confirm_mail_memberunconfirmed(self): + requested = self.reg.request_mail_confirmation() + self.assertTrue(requested) + self.assertIsNone(self.reg.confirm_mail('foobar')) + self.assertTrue(self.reg.confirm_mail(self.reg.confirm_mail_key)) + self.assertTrue(self.reg.confirm_mail(self.reg.confirm_alternative_mail_key)) + self.assertTrue(self.reg.registration_ready()) def test_contact_confirmation(self): # request mail confirmation of father @@ -1519,6 +1636,58 @@ class InvitationToGroupViewTestCase(BasicMemberTestCase): self.assertEqual(response.status_code, HTTPStatus.OK) +class InvitationToGroupTestCase(BasicMemberTestCase): + def setUp(self): + super().setUp() + self.waiter = MemberWaitingList.objects.create(**WAITER_DATA) + self.waiter.invite_to_group(self.alp) + self.invitation = InvitationToGroup.objects.get(group=self.alp, waiter=self.waiter) + self.invitation.created_by = self.fritz + + def test_status(self): + self.assertEqual(self.invitation.status(), _('Undecided')) + # expire the invitation + self.invitation.date = (timezone.now() - timezone.timedelta(days=100)).date() + self.assertTrue(self.invitation.is_expired()) + self.assertEqual(self.invitation.status(), _('Expired')) + # reject the invitation + self.invitation.reject() + self.assertEqual(self.invitation.status(), _('Rejected')) + + def test_confirm(self): + self.invitation.confirm() + self.assertFalse(self.invitation.rejected) + + def test_notify_left_waitinglist(self): + self.invitation.notify_left_waitinglist() + + +class MemberWaitingListTestCase(BasicMemberTestCase): + def setUp(self): + super().setUp() + self.waiter = MemberWaitingList.objects.create(**WAITER_DATA) + self.waiter.invite_to_group(self.alp) + self.invitation = InvitationToGroup.objects.get(group=self.alp, waiter=self.waiter) + + def test_latest_group_invitation(self): + self.assertGreater(len(self.waiter.latest_group_invitation()), 1) + + @skip("This currently throws a 'TypeError'.") + def test_may_register(self): + self.assertTrue(self.waiter.may_register(self.invitation.key)) + + def test_may_register_invalid(self): + self.assertFalse(self.waiter.may_register('foobar')) + + @skip("This currently throws a 'NameError'.") + def test_waiting_confirmation_needed(self): + self.assertFalse(self.waiter.waiting_confirmation_needed()) + + def test_confirm_waiting_invalid(self): + self.assertEqual(self.waiter.confirm_waiting('foobar'), + MemberWaitingList.WAITING_CONFIRMATION_INVALID) + + class ConfirmWaitingViewTestCase(BasicMemberTestCase): def setUp(self): super().setUp() @@ -1613,6 +1782,7 @@ class MailConfirmationViewTestCase(BasicMemberTestCase): self.assertEqual(response.status_code, HTTPStatus.OK) self.assertContains(response, _("Mail confirmed")) + class EchoViewTestCase(BasicMemberTestCase): def setUp(self): super().setUp() @@ -1725,6 +1895,7 @@ class TestRegistrationFilterTestCase(AdminTestCase): Member.objects.filter(registration_complete=True), ordered=False) + class MemberAdminFormTestCase(TestCase): def test_clean_iban(self): form_data = dict(REGISTRATION_DATA, iban='foobar') @@ -1941,3 +2112,83 @@ class GroupTestCase(BasicMemberTestCase): self.assertNotIn(url, spiel_text) self.assertIn(str(WEEKDAYS[self.alp.weekday][1]), alp_text) + + +class NewMemberOnListTestCase(BasicMemberTestCase): + def setUp(self): + super().setUp() + self.ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=120, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1) + self.cat = ActivityCategory.objects.create(name='crazy climbing', ljp_category='Klettern', + description='foobar') + self.ex.activity.add(self.cat) + self.ex.save() + self.mol = NewMemberOnList.objects.create(memberlist=self.ex, member=self.fritz) + + @skip("This currently throws a 'NameError'.") + def test_skills(self): + self.assertGreater(len(self.mol.skills), 0) + + @skip("This currently throws a 'NameError'.") + def test_qualities_tex(self): + self.assertGreater(len(self.mol.qualities_tex), 0) + + +class TrainingCategoryTestCase(TestCase): + def setUp(self): + self.cat = TrainingCategory.objects.create(name='school', permission_needed=True) + + def test_str(self): + self.assertEqual(str(self.cat), 'school') + +class PermissionMemberGroupTestCase(BasicMemberTestCase): + def setUp(self): + super().setUp() + self.gp = PermissionGroup.objects.create(group=self.alp) + self.gm = PermissionMember.objects.create(member=self.fritz) + + def test_str(self): + self.assertEqual(str(self.gp), _('Group permissions')) + self.assertEqual(str(self.gm), _('Permissions')) + + +class LJPProposalTestCase(TestCase): + def setUp(self): + self.proposal = LJPProposal.objects.create(title='Foo') + + def test_str(self): + self.assertEqual(str(self.proposal), 'Foo') + + +class KlettertreffTestCase(BasicMemberTestCase): + def setUp(self): + super().setUp() + self.kt = Klettertreff.objects.create(location='foo', topic='bar', group=self.alp) + self.kt.jugendleiter.add(self.fritz) + self.kt.save() + self.attendee = KlettertreffAttendee.objects.create(klettertreff=self.kt, member=self.peter) + + def test_str_attendee(self): + self.assertEqual(str(self.attendee), str(self.peter)) + + def test_get_jugendleiter(self): + self.assertIn(self.kt.get_jugendleiter(), self.fritz.name) + + def test_has_jugendleiter(self): + self.assertFalse(self.kt.has_jugendleiter(self.peter)) + self.assertTrue(self.kt.has_jugendleiter(self.fritz)) + + def test_has_attendee(self): + self.assertTrue(self.kt.has_attendee(self.peter)) + self.assertFalse(self.kt.has_attendee(self.fritz)) + + +class EmergencyContactTestCase(TestCase): + def setUp(self): + self.member = Member.objects.create(**REGISTRATION_DATA) + self.emergency_contact = EmergencyContact.objects.create(member=self.member) + + def test_str(self): + self.assertEqual(str(self.emergency_contact), str(self.member)) From 416af3607065fc50919ee3d2ff54a8d10d085ae2 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 4 May 2025 18:50:59 +0200 Subject: [PATCH 05/16] chore(members): reorganize tests --- jdav_web/members/tests/__init__.py | 1 + jdav_web/members/{tests.py => tests/basic.py} | 136 +------------ jdav_web/members/tests/utils.py | 191 ++++++++++++++++++ 3 files changed, 199 insertions(+), 129 deletions(-) create mode 100644 jdav_web/members/tests/__init__.py rename jdav_web/members/{tests.py => tests/basic.py} (93%) create mode 100644 jdav_web/members/tests/utils.py diff --git a/jdav_web/members/tests/__init__.py b/jdav_web/members/tests/__init__.py new file mode 100644 index 0000000..da0f2b6 --- /dev/null +++ b/jdav_web/members/tests/__init__.py @@ -0,0 +1 @@ +from .basic import * diff --git a/jdav_web/members/tests.py b/jdav_web/members/tests/basic.py similarity index 93% rename from jdav_web/members/tests.py rename to jdav_web/members/tests/basic.py index 4e59f15..20295a1 100644 --- a/jdav_web/members/tests.py +++ b/jdav_web/members/tests/basic.py @@ -14,17 +14,17 @@ from django.conf import settings from django.urls import reverse from django import template from unittest import skip, mock -from .models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR,\ +from members.models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR,\ MUSKELKRAFT_ANREISE, FUEHRUNGS_TOUR, AUSBILDUNGS_TOUR, OEFFENTLICHE_ANREISE,\ FAHRGEMEINSCHAFT_ANREISE,\ MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\ RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup, DIVERSE, MALE, FEMALE,\ Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\ TrainingCategory, Person -from .admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\ +from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\ MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\ MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin -from .pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs +from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs from mailer.models import EmailAddress from finance.models import Statement, Bill @@ -35,29 +35,9 @@ import datetime from dateutil.relativedelta import relativedelta import math import os.path +from members.tests.utils import * -INTERNAL_EMAIL = "foobar@{domain}".format(domain=settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]) -REGISTRATION_DATA = { - 'prename': 'Peter', - 'lastname': 'Wulter', - 'street': 'Street 123', - 'plz': '12345 EJ', - 'town': 'Town 1', - 'phone_number': '+49 123456', - 'birth_date': '2010-05-17', - 'gender': '2', - 'email': settings.TEST_MAIL, - 'alternative_email': settings.TEST_MAIL, -} -WAITER_DATA = { - 'prename': 'Peter', - 'lastname': 'Wulter', - 'birth_date': '1999-02-16', - 'gender': '0', - 'email': settings.TEST_MAIL, - 'application_text': 'hoho', -} EMERGENCY_CONTACT_DATA = { 'emergencycontact_set-TOTAL_FORMS': '1', 'emergencycontact_set-INITIAL_FORMS': '0', @@ -73,61 +53,6 @@ EMERGENCY_CONTACT_DATA = { } -def create_custom_user(username, groups, prename, lastname): - user = User.objects.create_user( - username=username, password='secret' - ) - member = Member.objects.create(prename=prename, lastname=lastname, birth_date=timezone.localdate(), email=settings.TEST_MAIL, gender=DIVERSE) - member.user = user - member.save() - user.is_staff = True - user.save() - - for group in groups: - g = authmodels.Group.objects.get(name=group) - user.groups.add(g) - return user - - -class BasicMemberTestCase(TestCase): - def setUp(self): - self.jl = Group.objects.create(name="Jugendleiter") - self.alp = Group.objects.create(name="Alpenfuechse") - self.spiel = Group.objects.create(name="Spielkinder") - - self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), - email=settings.TEST_MAIL, gender=DIVERSE) - self.fritz.group.add(self.jl) - self.fritz.group.add(self.alp) - self.fritz.save() - - em = EmailAddress.objects.create(name='foobar') - self.alp.contact_email = em - self.alp.save() - - self.peter = Member.objects.create(prename="Peter", lastname="Wulter", - birth_date=timezone.now().date(), - email=settings.TEST_MAIL, gender=MALE) - self.peter.group.add(self.jl) - self.peter.group.add(self.alp) - self.peter.save() - - self.lara = Member.objects.create(prename="Lara", lastname="Wallis", birth_date=timezone.now().date(), - email=INTERNAL_EMAIL, gender=DIVERSE) - self.lara.group.add(self.alp) - self.lara.save() - self.fridolin = Member.objects.create(prename="Fridolin", lastname="Spargel", birth_date=timezone.now().date(), - email=settings.TEST_MAIL, gender=MALE) - self.fridolin.group.add(self.alp) - self.fridolin.group.add(self.spiel) - self.fridolin.save() - - self.lise = Member.objects.create(prename="Lise", lastname="Lotte", birth_date=timezone.now().date(), - email=settings.TEST_MAIL, gender=FEMALE) - self.alp.leiters.add(self.lise) - self.alp.save() - - class MemberTestCase(BasicMemberTestCase): def setUp(self): super().setUp() @@ -693,60 +618,13 @@ class FreizeitTestCase(BasicMemberTestCase): 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): - m = Member.objects.create(prename='Peter {}'.format(i), - lastname='Wulter', - birth_date=datetime.datetime.today() - relativedelta(years=50), - email=settings.TEST_MAIL, - gender=FEMALE) - self.ex.jugendleiter.add(m) - NewMemberOnList.objects.create(member=m, comments='a', memberlist=self.ex) - for i in range(n_b27_local): - m = Member.objects.create(prename='Lise {}'.format(i), - lastname='Walter', - birth_date=datetime.datetime.today() - relativedelta(years=10), - town=settings.SEKTION, - email=settings.TEST_MAIL, - gender=FEMALE) - NewMemberOnList.objects.create(member=m, comments='a', memberlist=self.ex) - for i in range(n_b27_non_local): - m = Member.objects.create(prename='Lise {}'.format(i), - lastname='Walter', - birth_date=datetime.datetime.today() - relativedelta(years=10), - email=settings.TEST_MAIL, - gender=FEMALE) - NewMemberOnList.objects.create(member=m, comments='a', memberlist=self.ex) + add_memberonlist_by_local(self.ex, n_yl, n_b27_local, n_b27_non_local) def _setup_test_ljp_participant_count(self, n_yl, n_correct_age, n_too_old): - for i in range(n_yl): - # a 50 years old - m = Member.objects.create(prename='Peter {}'.format(i), - lastname='Wulter', - birth_date=datetime.datetime.today() - relativedelta(years=50), - email=settings.TEST_MAIL, - gender=FEMALE) - self.ex.jugendleiter.add(m) - for i in range(n_correct_age): - # a 10 years old - m = Member.objects.create(prename='Lise {}'.format(i), - lastname='Walter', - birth_date=datetime.datetime.today() - relativedelta(years=10), - email=settings.TEST_MAIL, - gender=FEMALE) - NewMemberOnList.objects.create(member=m, comments='a', memberlist=self.ex) - for i in range(n_too_old): - # a 27 years old - m = Member.objects.create(prename='Lise {}'.format(i), - lastname='Walter', - birth_date=datetime.datetime.today() - relativedelta(years=27), - email=settings.TEST_MAIL, - gender=FEMALE) - NewMemberOnList.objects.create(member=m, comments='a', memberlist=self.ex) + add_memberonlist_by_age(self.ex, n_yl, n_correct_age, n_too_old) def _cleanup_excursion(self): - # delete all members on excursion for clean up - NewMemberOnList.objects.all().delete() - self.ex.jugendleiter.all().delete() + cleanup_excursion(self.ex) def _test_theoretic_ljp_participant_count_proportion(self, n_yl, n_correct_age, n_too_old): self._setup_test_ljp_participant_count(n_yl, n_correct_age, n_too_old) diff --git a/jdav_web/members/tests/utils.py b/jdav_web/members/tests/utils.py new file mode 100644 index 0000000..f7928e2 --- /dev/null +++ b/jdav_web/members/tests/utils.py @@ -0,0 +1,191 @@ +from http import HTTPStatus + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.contrib.auth import models as authmodels +from django.contrib.admin.sites import AdminSite +from django.contrib.messages import get_messages +from django.contrib.auth.models import User +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from django.test import TestCase, Client, RequestFactory +from django.utils import timezone, translation +from django.conf import settings +from django.urls import reverse +from django import template +from unittest import skip, mock +from members.models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR,\ + MUSKELKRAFT_ANREISE, FUEHRUNGS_TOUR, AUSBILDUNGS_TOUR, OEFFENTLICHE_ANREISE,\ + FAHRGEMEINSCHAFT_ANREISE,\ + MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\ + RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup, DIVERSE, MALE, FEMALE,\ + Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\ + TrainingCategory, Person +from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\ + MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\ + MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin +from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs +from mailer.models import EmailAddress +from finance.models import Statement, Bill + +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +import random +import datetime +from dateutil.relativedelta import relativedelta +import math +import os.path + + +INTERNAL_EMAIL = "foobar@{domain}".format(domain=settings.ALLOWED_EMAIL_DOMAINS_FOR_INVITE_AS_USER[0]) +REGISTRATION_DATA = { + 'prename': 'Peter', + 'lastname': 'Wulter', + 'street': 'Street 123', + 'plz': '12345 EJ', + 'town': 'Town 1', + 'phone_number': '+49 123456', + 'birth_date': '2010-05-17', + 'gender': '2', + 'email': settings.TEST_MAIL, + 'alternative_email': settings.TEST_MAIL, +} +WAITER_DATA = { + 'prename': 'Peter', + 'lastname': 'Wulter', + 'birth_date': '1999-02-16', + 'gender': '0', + 'email': settings.TEST_MAIL, + 'application_text': 'hoho', +} + + +def create_custom_user(username, groups, prename, lastname): + user = User.objects.create_user( + username=username, password='secret' + ) + member = Member.objects.create(prename=prename, lastname=lastname, birth_date=timezone.localdate(), email=settings.TEST_MAIL, gender=DIVERSE) + member.user = user + member.save() + user.is_staff = True + user.save() + + for group in groups: + g = authmodels.Group.objects.get(name=group) + user.groups.add(g) + return user + + +class BasicMemberTestCase(TestCase): + """ + Utility base class for setting up a test environment for member-related tests. + It creates a few groups and members with different attributes. + """ + def setUp(self): + self.jl = Group.objects.create(name="Jugendleiter") + self.alp = Group.objects.create(name="Alpenfuechse") + self.spiel = Group.objects.create(name="Spielkinder") + + self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), + email=settings.TEST_MAIL, gender=DIVERSE) + self.fritz.group.add(self.jl) + self.fritz.group.add(self.alp) + self.fritz.save() + + em = EmailAddress.objects.create(name='foobar') + self.alp.contact_email = em + self.alp.save() + + self.peter = Member.objects.create(prename="Peter", lastname="Wulter", + birth_date=timezone.now().date(), + email=settings.TEST_MAIL, gender=MALE) + self.peter.group.add(self.jl) + self.peter.group.add(self.alp) + self.peter.save() + + self.lara = Member.objects.create(prename="Lara", lastname="Wallis", birth_date=timezone.now().date(), + email=INTERNAL_EMAIL, gender=DIVERSE) + self.lara.group.add(self.alp) + self.lara.save() + self.fridolin = Member.objects.create(prename="Fridolin", lastname="Spargel", birth_date=timezone.now().date(), + email=settings.TEST_MAIL, gender=MALE) + self.fridolin.group.add(self.alp) + self.fridolin.group.add(self.spiel) + self.fridolin.save() + + self.lise = Member.objects.create(prename="Lise", lastname="Lotte", birth_date=timezone.now().date(), + email=settings.TEST_MAIL, gender=FEMALE) + self.alp.leiters.add(self.lise) + self.alp.save() + + +def add_memberonlist_by_age(excursion, n_yl, n_correct_age, n_too_old): + """ + Utility function for setting up a test environment. Adds `n_yl` youth leaders, + `n_correct_age` members of correct age (i.e. 10 years olds) and + `n_too_old` members that are too old (i.e. 27 years olds) to `excursion`. + """ + for i in range(n_yl): + # a 50 years old + m = Member.objects.create(prename='Peter {}'.format(i), + lastname='Wulter', + birth_date=datetime.datetime.today() - relativedelta(years=50), + email=settings.TEST_MAIL, + gender=FEMALE) + excursion.jugendleiter.add(m) + for i in range(n_correct_age): + # a 10 years old + m = Member.objects.create(prename='Lise {}'.format(i), + lastname='Walter', + birth_date=datetime.datetime.today() - relativedelta(years=10), + email=settings.TEST_MAIL, + gender=FEMALE) + NewMemberOnList.objects.create(member=m, comments='a', memberlist=excursion) + for i in range(n_too_old): + # a 27 years old + m = Member.objects.create(prename='Lise {}'.format(i), + lastname='Walter', + birth_date=datetime.datetime.today() - relativedelta(years=27), + email=settings.TEST_MAIL, + gender=FEMALE) + NewMemberOnList.objects.create(member=m, comments='a', memberlist=excursion) + + +def add_memberonlist_by_local(excursion, n_yl, n_b27_local, n_b27_non_local): + """ + Utility function for setting up a test environment. Adds `n_yl` youth leaders, + `n_b27_local` local members of correct age and + `n_b27_non_local` non-local members of correct age to `excursion`. + """ + for i in range(n_yl): + m = Member.objects.create(prename='Peter {}'.format(i), + lastname='Wulter', + birth_date=datetime.datetime.today() - relativedelta(years=50), + email=settings.TEST_MAIL, + gender=FEMALE) + excursion.jugendleiter.add(m) + NewMemberOnList.objects.create(member=m, comments='a', memberlist=excursion) + for i in range(n_b27_local): + m = Member.objects.create(prename='Lise {}'.format(i), + lastname='Walter', + birth_date=datetime.datetime.today() - relativedelta(years=10), + town=settings.SEKTION, + email=settings.TEST_MAIL, + gender=FEMALE) + NewMemberOnList.objects.create(member=m, comments='a', memberlist=excursion) + for i in range(n_b27_non_local): + m = Member.objects.create(prename='Lise {}'.format(i), + lastname='Walter', + birth_date=datetime.datetime.today() - relativedelta(years=10), + email=settings.TEST_MAIL, + gender=FEMALE) + NewMemberOnList.objects.create(member=m, comments='a', memberlist=excursion) + + +def cleanup_excursion(excursion): + """ + Utility function for cleaning up a test environment. Deletes all members and + youth leaders from `excursion`. + """ + excursion.membersonlist.all().delete() + excursion.jugendleiter.all().delete() From a8d46257191e2183b40ba09f9271383e801ceb74 Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Thu, 24 Jul 2025 22:55:39 +0200 Subject: [PATCH 06/16] feat(finance/tests): tests for new rules (#155) Also makes some checks safe. Reviewed-on: https://git.jdav-hd.merten.dev/digitales/kompass/pulls/155 Reviewed-by: Christian Merten Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/finance/models.py | 5 ++ jdav_web/finance/tests.py | 113 ++++++++++++++++++++++++++++++++++++- jdav_web/members/models.py | 13 ++++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index c09cf22..b9c8a7a 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -405,6 +405,8 @@ class Statement(CommonModel): @property def org_fee_payant(self): + if self.total_org_fee == 0: + return None return self.subsidy_to if self.subsidy_to else self.allowance_to.all()[0] @property @@ -466,8 +468,11 @@ class Statement(CommonModel): return cvt_to_decimal( min( + # if total costs are more than the max amount of the LJP contribution, we pay the max amount, reduced by taxes (1-settings.LJP_TAX) * settings.LJP_CONTRIBUTION_PER_DAY * self.excursion.ljp_participant_count * self.excursion.ljp_duration, + # if the total costs are less than the max amount, we pay up to 90% of the total costs, reduced by taxes (1-settings.LJP_TAX) * 0.9 * (float(self.total_bills_not_covered) + float(self.total_staff) ), + # we never pay more than the maximum costs of the trip float(self.total_bills_not_covered) ) ) diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests.py index 9e04c97..949b124 100644 --- a/jdav_web/finance/tests.py +++ b/jdav_web/finance/tests.py @@ -5,8 +5,9 @@ from django.conf import settings from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\ StatementConfirmed, TransactionIssue, StatementManager -from members.models import Member, Group, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ +from members.models import Member, Group, Freizeit, LJPProposal, Intervention, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ FAHRGEMEINSCHAFT_ANREISE, MALE, FEMALE, DIVERSE +from dateutil.relativedelta import relativedelta # Create your tests here. class StatementTestCase(TestCase): @@ -66,6 +67,116 @@ class StatementTestCase(TestCase): email=settings.TEST_MAIL, gender=DIVERSE) mol = NewMemberOnList.objects.create(member=m, memberlist=ex) ex.membersonlist.add(mol) + + base = timezone.now() + ex = Freizeit.objects.create(name='Wild trip with old people', kilometers_traveled=self.kilometers_traveled, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=2, date=timezone.datetime(2024, 1, 2, 8, 0, 0, tzinfo=base.tzinfo), end=timezone.datetime(2024, 1, 5, 17, 0, 0, tzinfo=base.tzinfo) ) + + settings.EXCURSION_ORG_FEE = 20 + settings.LJP_TAX = 0.2 + settings.LJP_CONTRIBUTION_PER_DAY = 20 + + self.st5 = Statement.objects.create(night_cost=self.night_cost, excursion=ex) + + for i in range(9): + m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date() - relativedelta(years=i+21), + email=settings.TEST_MAIL, gender=DIVERSE) + mol = NewMemberOnList.objects.create(member=m, memberlist=ex) + ex.membersonlist.add(mol) + + ljpproposal = LJPProposal.objects.create( + title='Test proposal', + category=LJPProposal.LJP_STAFF_TRAINING, + goal=LJPProposal.LJP_ENVIRONMENT, + goal_strategy='my strategy', + not_bw_reason=LJPProposal.NOT_BW_ROOMS, + excursion=self.st5.excursion) + + for i in range(3): + int = Intervention.objects.create( + date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo), + duration = 2+i, + activity = 'hi', + ljp_proposal=ljpproposal + ) + + self.b1 = Bill.objects.create( + statement=self.st5, + short_description='covered bill', + explanation='hi', + amount='300', + paid_by=self.fritz, + costs_covered=True, + refunded=False + ) + + self.b2 = Bill.objects.create( + statement=self.st5, + short_description='non-covered bill', + explanation='hi', + amount='900', + paid_by=self.fritz, + costs_covered=False, + refunded=False + ) + + def test_org_fee(self): + # org fee should be collected if participants are older than 26 + self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.') + + total_org = 4 * 3 * 20 # 4 days, 3 old people, 20€ per day + + self.assertEqual(self.st5.total_org_fee_theoretical, total_org, 'Theoretical org_fee should equal to amount per day per person * n_persons * n_days if there are old people.') + self.assertEqual(self.st5.total_org_fee, 0, 'Paid org fee should be 0 if no allowance and subsidies are paid if there are old people.') + + self.assertIsNone(self.st5.org_fee_payant) + + # now collect subsidies + self.st5.subsidy_to = self.fritz + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies are paid.') + + # now collect allowances + self.st5.allowance_to.add(self.fritz) + self.st5.subsidy_to = None + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if allowances are paid.') + + # now collect both + self.st5.subsidy_to = self.fritz + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies and allowances are paid.') + + self.assertEqual(self.st5.org_fee_payant, self.fritz, 'Org fee payant should be the receiver allowances and subsidies.') + + # return to previous state + self.st5.subsidy_to = None + self.st5.allowance_to.remove(self.fritz) + + + def test_ljp_payment(self): + + expected_intervention_hours = 2 + 3 + 4 + expected_seminar_days = 0 + 0.5 + 0.5 # >=2.5h = 0.5days, >=5h = 1.0day + expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9 + # (1 - 20% tax) * 1 seminar day * 20€ * 9 participants + + self.assertEqual(self.st5.excursion.total_intervention_hours, expected_intervention_hours, 'Calculation of total intervention hours is incorrect.') + self.assertEqual(self.st5.excursion.total_seminar_days, expected_seminar_days, 'Calculation of total seminar days is incorrect.') + + self.assertEqual(self.st5.paid_ljp_contributions, 0, 'No LJP contributions should be paid if no receiver is set.') + + # now we want to pay out the LJP contributions + self.st5.ljp_to = self.fritz + self.assertEqual(self.st5.paid_ljp_contributions, expected_ljp, 'LJP contributions should be paid if a receiver is set.') + + # now the total costs paid by trip organisers is lower than expected ljp contributions, should be reduced automatically + self.b2.amount=100 + self.b2.save() + + self.assertEqual(self.st5.total_bills_not_covered, 100, 'Changes in bills should be reflected in the total costs paid by trip organisers') + self.assertGreaterEqual(self.st5.total_bills_not_covered, self.st5.paid_ljp_contributions, 'LJP contributions should be less than or equal to the costs paid by trip organisers') + + self.st5.ljp_to = None def test_staff_count(self): self.assertEqual(self.st4.admissible_staff_count, 0, diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 1700f2b..a5377e3 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -1423,23 +1423,30 @@ class Freizeit(CommonModel): @property def maximal_ljp_contributions(self): + """This is the maximal amount of LJP contributions that can be requested given participants and length + This calculation if intended for the LJP application, not for the payout.""" return cvt_to_decimal(settings.LJP_CONTRIBUTION_PER_DAY * self.ljp_participant_count * self.duration) @property def potential_ljp_contributions(self): + """The maximal amount can be reduced if the actual costs are lower than the maximal amount + This calculation if intended for the LJP application, not for the payout.""" + if not hasattr(self, 'statement'): + return cvt_to_decimal(0) 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: + """the payable contributions can differ from potential contributions if a tax is deducted for risk reduction. + the actual payout depends on more factors, e.g. the actual costs that had to be paid by the trip organisers.""" + if hasattr(self, 'statement') and 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: + if not hasattr(self, 'statement'): return 0 total_costs = self.statement.total_bills_theoretic total_contributions = self.statement.total_subsidies + self.payable_ljp_contributions From 2cee336397cac9f9f1d253b491320c5e90363ed6 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Fri, 25 Jul 2025 00:54:17 +0200 Subject: [PATCH 07/16] chore: add license --- LICENSE | 661 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. From e02f728e8a240affd5d92c2d9b408187c1c2fe3e Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Fri, 25 Jul 2025 21:29:18 +0200 Subject: [PATCH 08/16] feat(members/waitinglist): add group age range info to invite text (#168) Pass age info to group invite text as a parameter. Reviewed-by: Christian Merten Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/members/locale/de/LC_MESSAGES/django.po | 11 ++++++++++- jdav_web/members/models.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 8a7f47a..3344f9c 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-05-03 18:06+0200\n" +"POT-Creation-Date: 2025-07-25 18:44+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -599,6 +599,15 @@ msgstr "Gruppe" msgid "groups" msgstr "Gruppen" +#: members/models.py +#, python-format +msgid "years %(from)s to %(to)s" +msgstr "Jahrgang %(from)s bis %(to)s" + +#: members/models.py +msgid "no information available" +msgstr "keine Angabe" + #: members/models.py msgid "prename" msgstr "Vorname" diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index a5377e3..7531680 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -119,6 +119,15 @@ class Group(models.Model): end_time=self.end_time.strftime('%H:%M')) else: return "" + + def has_age_info(self): + return self.year_from and self.year_to + + def get_age_info(self): + if self.has_age_info(): + return _("years %(from)s to %(to)s") % {'from':self.year_from, 'to':self.year_to} + else: + return "" def get_invitation_text_template(self): """The text template used to invite waiters to this group. This contains @@ -131,8 +140,14 @@ class Group(models.Model): group_time = self.get_time_info() else: group_time = settings.GROUP_TIME_UNAVAILABLE_TEXT.format(contact_email=self.contact_email) + if self.has_age_info(): + group_age = self.get_age_info() + else: + group_age = _("no information available") + return settings.INVITE_TEXT.format(group_time=group_time, group_name=self.name, + group_age=group_age, group_link=group_link, contact_email=self.contact_email) From 7f203b513927415cca3b595b492c80204a020a9b Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 28 Jul 2025 22:31:55 +0200 Subject: [PATCH 09/16] feat(contrib/management): add command to create a superuser from env variables --- .../management/commands/ensuresuperuser.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 jdav_web/contrib/management/commands/ensuresuperuser.py diff --git a/jdav_web/contrib/management/commands/ensuresuperuser.py b/jdav_web/contrib/management/commands/ensuresuperuser.py new file mode 100644 index 0000000..d701d64 --- /dev/null +++ b/jdav_web/contrib/management/commands/ensuresuperuser.py @@ -0,0 +1,28 @@ +import os +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = "Creates a super-user non-interactively if it doesn't exist." + + def handle(self, *args, **options): + User = get_user_model() + + username = os.environ.get('DJANGO_SUPERUSER_USERNAME', '') + password = os.environ.get('DJANGO_SUPERUSER_PASSWORD', '') + + if not username or not password: + self.stdout.write( + self.style.WARNING('Superuser data was not set. Skipping.') + ) + return + + if not User.objects.filter(username=username).exists(): + User.objects.create_superuser(username=username, password=password) + self.stdout.write( + self.style.SUCCESS('Successfully created superuser.') + ) + else: + self.stdout.write( + self.style.SUCCESS('Superuser with configured username already exists. Skipping.') + ) From 99f6dfcdfb932f256ed24711ccf4db75c11dd55d Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Mon, 28 Jul 2025 22:34:07 +0200 Subject: [PATCH 10/16] feat(docker/production): create superuser in initial setup We add one step to the master entrypoint script to ensure a superuser exists with username and password configured from the environment variables DJANGO_SUPERUSER_USERNAME and DJANGO_SUPERUSER_PASSWORD. The step does nothing if these variables are not set or the user already exists. --- docker/production/entrypoint-master.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/production/entrypoint-master.sh b/docker/production/entrypoint-master.sh index 3ecdd40..a6ba638 100755 --- a/docker/production/entrypoint-master.sh +++ b/docker/production/entrypoint-master.sh @@ -16,6 +16,7 @@ if ! [ -f completed_initial_run ]; then python jdav_web/manage.py compilemessages --locale de python jdav_web/manage.py migrate + python jdav_web/manage.py ensuresuperuser touch completed_initial_run fi From a9b26e529b6dd0b3b0414d1a6ab268611887dda4 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 16 Aug 2025 01:03:25 +0200 Subject: [PATCH 11/16] feat(*): add more tests --- jdav_web/contrib/tests.py | 55 ++++++++++- jdav_web/finance/tests.py | 190 ++++++++++++++++++++++++++++--------- jdav_web/material/tests.py | 113 +++++++++++++++++++++- 3 files changed, 311 insertions(+), 47 deletions(-) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 7ce503c..41e3726 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -1,3 +1,56 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from contrib.models import CommonModel +from contrib.rules import has_global_perm -# Create your tests here. +User = get_user_model() + +class CommonModelTestCase(TestCase): + def test_common_model_abstract_base(self): + """Test that CommonModel provides the correct meta attributes""" + meta = CommonModel._meta + self.assertTrue(meta.abstract) + expected_permissions = ( + 'add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view', + ) + self.assertEqual(meta.default_permissions, expected_permissions) + + def test_common_model_inheritance(self): + """Test that CommonModel has rules mixin functionality""" + # Test that CommonModel has the expected functionality + # Since it's abstract, we can't instantiate it directly + # but we can check its metaclass and mixins + from rules.contrib.models import RulesModelMixin, RulesModelBase + + self.assertTrue(issubclass(CommonModel, RulesModelMixin)) + self.assertEqual(CommonModel.__class__, RulesModelBase) + + +class GlobalPermissionRulesTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123' + ) + + def test_has_global_perm_predicate_creation(self): + """Test that has_global_perm creates a predicate function""" + # has_global_perm is a decorator factory, not a direct predicate + predicate = has_global_perm('auth.add_user') + self.assertTrue(callable(predicate)) + + def test_has_global_perm_with_superuser(self): + """Test that superusers have global permissions""" + self.user.is_superuser = True + self.user.save() + + predicate = has_global_perm('auth.add_user') + result = predicate(self.user, None) + self.assertTrue(result) + + def test_has_global_perm_with_regular_user(self): + """Test that regular users don't automatically have global permissions""" + predicate = has_global_perm('auth.add_user') + result = predicate(self.user, None) + self.assertFalse(result) diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests.py index 949b124..b9f5eea 100644 --- a/jdav_web/finance/tests.py +++ b/jdav_web/finance/tests.py @@ -2,6 +2,7 @@ from unittest import skip from django.test import TestCase from django.utils import timezone from django.conf import settings +from decimal import Decimal from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\ StatementConfirmed, TransactionIssue, StatementManager @@ -67,115 +68,115 @@ class StatementTestCase(TestCase): email=settings.TEST_MAIL, gender=DIVERSE) mol = NewMemberOnList.objects.create(member=m, memberlist=ex) ex.membersonlist.add(mol) - + base = timezone.now() ex = Freizeit.objects.create(name='Wild trip with old people', kilometers_traveled=self.kilometers_traveled, tour_type=GEMEINSCHAFTS_TOUR, tour_approach=MUSKELKRAFT_ANREISE, difficulty=2, date=timezone.datetime(2024, 1, 2, 8, 0, 0, tzinfo=base.tzinfo), end=timezone.datetime(2024, 1, 5, 17, 0, 0, tzinfo=base.tzinfo) ) - + settings.EXCURSION_ORG_FEE = 20 settings.LJP_TAX = 0.2 settings.LJP_CONTRIBUTION_PER_DAY = 20 - + self.st5 = Statement.objects.create(night_cost=self.night_cost, excursion=ex) - + for i in range(9): m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date() - relativedelta(years=i+21), email=settings.TEST_MAIL, gender=DIVERSE) mol = NewMemberOnList.objects.create(member=m, memberlist=ex) - ex.membersonlist.add(mol) - + ex.membersonlist.add(mol) + ljpproposal = LJPProposal.objects.create( - title='Test proposal', + title='Test proposal', category=LJPProposal.LJP_STAFF_TRAINING, goal=LJPProposal.LJP_ENVIRONMENT, goal_strategy='my strategy', not_bw_reason=LJPProposal.NOT_BW_ROOMS, excursion=self.st5.excursion) - + for i in range(3): int = Intervention.objects.create( - date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo), - duration = 2+i, + date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo), + duration = 2+i, activity = 'hi', ljp_proposal=ljpproposal ) - + self.b1 = Bill.objects.create( - statement=self.st5, - short_description='covered bill', - explanation='hi', - amount='300', - paid_by=self.fritz, - costs_covered=True, + statement=self.st5, + short_description='covered bill', + explanation='hi', + amount='300', + paid_by=self.fritz, + costs_covered=True, refunded=False ) self.b2 = Bill.objects.create( - statement=self.st5, - short_description='non-covered bill', - explanation='hi', - amount='900', - paid_by=self.fritz, - costs_covered=False, + statement=self.st5, + short_description='non-covered bill', + explanation='hi', + amount='900', + paid_by=self.fritz, + costs_covered=False, refunded=False ) - + def test_org_fee(self): # org fee should be collected if participants are older than 26 self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.') - + total_org = 4 * 3 * 20 # 4 days, 3 old people, 20€ per day - + self.assertEqual(self.st5.total_org_fee_theoretical, total_org, 'Theoretical org_fee should equal to amount per day per person * n_persons * n_days if there are old people.') self.assertEqual(self.st5.total_org_fee, 0, 'Paid org fee should be 0 if no allowance and subsidies are paid if there are old people.') - + self.assertIsNone(self.st5.org_fee_payant) - + # now collect subsidies self.st5.subsidy_to = self.fritz self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies are paid.') - + # now collect allowances self.st5.allowance_to.add(self.fritz) self.st5.subsidy_to = None self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if allowances are paid.') - + # now collect both self.st5.subsidy_to = self.fritz self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies and allowances are paid.') - + self.assertEqual(self.st5.org_fee_payant, self.fritz, 'Org fee payant should be the receiver allowances and subsidies.') # return to previous state self.st5.subsidy_to = None self.st5.allowance_to.remove(self.fritz) - - + + def test_ljp_payment(self): - + expected_intervention_hours = 2 + 3 + 4 expected_seminar_days = 0 + 0.5 + 0.5 # >=2.5h = 0.5days, >=5h = 1.0day - expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9 - # (1 - 20% tax) * 1 seminar day * 20€ * 9 participants - + expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9 + # (1 - 20% tax) * 1 seminar day * 20€ * 9 participants + self.assertEqual(self.st5.excursion.total_intervention_hours, expected_intervention_hours, 'Calculation of total intervention hours is incorrect.') self.assertEqual(self.st5.excursion.total_seminar_days, expected_seminar_days, 'Calculation of total seminar days is incorrect.') - + self.assertEqual(self.st5.paid_ljp_contributions, 0, 'No LJP contributions should be paid if no receiver is set.') - + # now we want to pay out the LJP contributions self.st5.ljp_to = self.fritz self.assertEqual(self.st5.paid_ljp_contributions, expected_ljp, 'LJP contributions should be paid if a receiver is set.') - + # now the total costs paid by trip organisers is lower than expected ljp contributions, should be reduced automatically self.b2.amount=100 self.b2.save() - + self.assertEqual(self.st5.total_bills_not_covered, 100, 'Changes in bills should be reflected in the total costs paid by trip organisers') self.assertGreaterEqual(self.st5.total_bills_not_covered, self.st5.paid_ljp_contributions, 'LJP contributions should be less than or equal to the costs paid by trip organisers') - + self.st5.ljp_to = None def test_staff_count(self): @@ -371,6 +372,41 @@ class StatementTestCase(TestCase): bills = self.st2.grouped_bills() self.assertTrue('amount' in bills[0]) + def test_euro_per_km_no_excursion(self): + """Test euro_per_km when no excursion is associated""" + statement = Statement.objects.create( + short_description="Test Statement", + explanation="Test explanation", + night_cost=25 + ) + self.assertEqual(statement.euro_per_km, 0) + + def test_submit_workflow(self): + """Test statement submission workflow""" + statement = Statement.objects.create( + short_description="Test Statement", + explanation="Test explanation", + night_cost=25, + created_by=self.fritz + ) + + self.assertFalse(statement.submitted) + self.assertIsNone(statement.submitted_by) + self.assertIsNone(statement.submitted_date) + + # Test submission - submit method doesn't return a value, just changes state + statement.submit(submitter=self.fritz) + self.assertTrue(statement.submitted) + self.assertEqual(statement.submitted_by, self.fritz) + self.assertIsNotNone(statement.submitted_date) + + def test_template_context_with_excursion(self): + """Test statement template context when excursion is present""" + # Use existing excursion from setUp + context = self.st3.template_context() + self.assertIn('euro_per_km', context) + self.assertIsInstance(context['euro_per_km'], (int, float, Decimal)) + class LedgerTestCase(TestCase): def setUp(self): @@ -431,9 +467,20 @@ class TransactionTestCase(TestCase): self.assertTrue(str(self.trans.pk) in str(self.trans)) def test_escape_reference(self): - self.assertEqual(Transaction.escape_reference('harmless'), 'harmless') - self.assertEqual(Transaction.escape_reference('äöüÄÖÜß'), 'aeoeueAeOeUess') - self.assertEqual(Transaction.escape_reference('ha@r!?mless+09'), 'har?mless+09') + """Test transaction reference escaping with various special characters""" + test_cases = [ + ('harmless', 'harmless'), + ('äöüÄÖÜß', 'aeoeueAeOeUess'), + ('ha@r!?mless+09', 'har?mless+09'), + ("simple", "simple"), + ("test@email.com", "testemail.com"), + ("ref!with#special$chars%", "refwithspecialchars"), + ("normal_text-123", "normaltext-123"), # underscores are removed + ] + + for input_ref, expected in test_cases: + result = Transaction.escape_reference(input_ref) + self.assertEqual(result, expected) def test_code(self): self.trans.amount = 0 @@ -446,6 +493,35 @@ class TransactionTestCase(TestCase): self.fritz.iban = 'DE89370400440532013000' self.assertNotEqual(self.trans.code(), '') + def test_code_with_zero_amount(self): + """Test transaction code generation with zero amount""" + transaction = Transaction.objects.create( + reference="test-ref", + amount=Decimal('0.00'), + member=self.fritz, + ledger=self.personal_account, + statement=self.st + ) + + # Zero amount should return empty code + self.assertEqual(transaction.code(), '') + + def test_code_with_invalid_iban(self): + """Test transaction code generation with invalid IBAN""" + self.fritz.iban = "INVALID_IBAN" + self.fritz.save() + + transaction = Transaction.objects.create( + reference="test-ref", + amount=Decimal('100.00'), + member=self.fritz, + ledger=self.personal_account, + statement=self.st + ) + + # Invalid IBAN should return empty code + self.assertEqual(transaction.code(), '') + class BillTestCase(TestCase): def setUp(self): @@ -461,6 +537,30 @@ class BillTestCase(TestCase): def test_pretty_amount(self): self.assertTrue('€' in self.bill.pretty_amount()) + def test_pretty_amount_formatting(self): + """Test bill pretty_amount formatting with specific values""" + bill = Bill.objects.create( + statement=self.st, + short_description="Test Bill", + amount=Decimal('42.50') + ) + + pretty = bill.pretty_amount() + self.assertIn("42.50", pretty) + self.assertIn("€", pretty) + + def test_zero_amount(self): + """Test bill with zero amount""" + bill = Bill.objects.create( + statement=self.st, + short_description="Zero Bill", + amount=Decimal('0.00') + ) + + self.assertEqual(bill.amount, Decimal('0.00')) + pretty = bill.pretty_amount() + self.assertIn("0.00", pretty) + class TransactionIssueTestCase(TestCase): def setUp(self): diff --git a/jdav_web/material/tests.py b/jdav_web/material/tests.py index 7ce503c..53ee4ec 100644 --- a/jdav_web/material/tests.py +++ b/jdav_web/material/tests.py @@ -1,3 +1,114 @@ from django.test import TestCase +from django.utils import timezone +from datetime import date +from decimal import Decimal +from material.models import MaterialCategory, MaterialPart, Ownership +from members.models import Member, MALE, FEMALE, DIVERSE -# Create your tests here. + +class MaterialCategoryTestCase(TestCase): + def setUp(self): + self.category = MaterialCategory.objects.create(name="Climbing Gear") + + def test_str(self): + """Test string representation of MaterialCategory""" + self.assertEqual(str(self.category), "Climbing Gear") + + def test_verbose_names(self): + """Test verbose names are set correctly""" + meta = MaterialCategory._meta + self.assertTrue(hasattr(meta, 'verbose_name')) + self.assertTrue(hasattr(meta, 'verbose_name_plural')) + + +class MaterialPartTestCase(TestCase): + def setUp(self): + self.category = MaterialCategory.objects.create(name="Ropes") + self.material_part = MaterialPart.objects.create( + name="Dynamic Rope 10mm", + description="60m dynamic climbing rope", + quantity=5, + buy_date=date(2020, 1, 15), + lifetime=Decimal('8') + ) + self.material_part.material_cat.add(self.category) + + self.member = Member.objects.create( + prename="John", + lastname="Doe", + birth_date=date(1990, 1, 1), + email="john@example.com", + gender=MALE + ) + + def test_str(self): + """Test string representation of MaterialPart""" + self.assertEqual(str(self.material_part), "Dynamic Rope 10mm") + + def test_quantity_real_no_ownership(self): + """Test quantity_real when no ownership exists""" + result = self.material_part.quantity_real() + self.assertEqual(result, "0/5") + + def test_quantity_real_with_ownership(self): + """Test quantity_real with ownership records""" + Ownership.objects.create( + material=self.material_part, + owner=self.member, + count=3 + ) + Ownership.objects.create( + material=self.material_part, + owner=self.member, + count=1 + ) + result = self.material_part.quantity_real() + self.assertEqual(result, "4/5") + + def test_verbose_names(self): + """Test field verbose names""" + # Just test that verbose names exist, since they might be translated + field_names = ['name', 'description', 'quantity', 'buy_date', 'lifetime', 'photo', 'material_cat'] + + for field_name in field_names: + field = self.material_part._meta.get_field(field_name) + self.assertTrue(hasattr(field, 'verbose_name')) + self.assertIsNotNone(field.verbose_name) + + +class OwnershipTestCase(TestCase): + def setUp(self): + self.category = MaterialCategory.objects.create(name="Hardware") + self.material_part = MaterialPart.objects.create( + name="Carabiner Set", + description="Lightweight aluminum carabiners", + quantity=10, + buy_date=date(2021, 6, 1), + lifetime=Decimal('10') + ) + + self.member = Member.objects.create( + prename="Alice", + lastname="Smith", + birth_date=date(1985, 3, 15), + email="alice@example.com", + gender=FEMALE + ) + + self.ownership = Ownership.objects.create( + material=self.material_part, + owner=self.member, + count=6 + ) + + def test_ownership_creation(self): + """Test ownership record creation""" + self.assertEqual(self.ownership.material, self.material_part) + self.assertEqual(self.ownership.owner, self.member) + self.assertEqual(self.ownership.count, 6) + + def test_material_part_relationship(self): + """Test relationship between MaterialPart and Ownership""" + ownerships = Ownership.objects.filter(material=self.material_part) + self.assertEqual(ownerships.count(), 1) + self.assertEqual(ownerships.first(), self.ownership) From 396ea6f796c1f594f55598f76ca085a220418473 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 16 Aug 2025 16:24:14 +0200 Subject: [PATCH 12/16] chore(finance/tests): reorganise and add admin tests --- jdav_web/finance/tests/__init__.py | 2 + jdav_web/finance/tests/admin.py | 342 ++++++++++++++++++ .../finance/{tests.py => tests/models.py} | 3 +- 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 jdav_web/finance/tests/__init__.py create mode 100644 jdav_web/finance/tests/admin.py rename jdav_web/finance/{tests.py => tests/models.py} (99%) diff --git a/jdav_web/finance/tests/__init__.py b/jdav_web/finance/tests/__init__.py new file mode 100644 index 0000000..4754139 --- /dev/null +++ b/jdav_web/finance/tests/__init__.py @@ -0,0 +1,2 @@ +from .admin import * +from .models import * diff --git a/jdav_web/finance/tests/admin.py b/jdav_web/finance/tests/admin.py new file mode 100644 index 0000000..892338e --- /dev/null +++ b/jdav_web/finance/tests/admin.py @@ -0,0 +1,342 @@ +from django.test import TestCase, override_settings +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory, Client +from django.contrib.auth.models import User, Permission +from django.utils import timezone +from django.contrib.sessions.middleware import SessionMiddleware +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.messages.storage.fallback import FallbackStorage +from django.utils.translation import gettext_lazy as _ + +from members.models import Member, MALE +from ..models import Ledger, Statement, StatementConfirmed, Transaction, Bill +from ..admin import ( + LedgerAdmin, StatementUnSubmittedAdmin, StatementSubmittedAdmin, + StatementConfirmedAdmin, TransactionAdmin, BillAdmin +) + + +class StatementUnSubmittedAdminTestCase(TestCase): + """Test cases for StatementUnSubmittedAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = StatementUnSubmittedAdmin(Statement, self.site) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.statement = Statement.objects.create( + short_description='Test Statement', + explanation='Test explanation', + night_cost=25 + ) + + def _add_session_to_request(self, request): + """Add session to request""" + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(lambda req: None) + middleware.process_request(request) + request._messages = FallbackStorage(request) + + def test_save_model_with_member(self): + """Test save_model sets created_by for new objects""" + request = self.factory.post('/') + request.user = self.user + + # Test with change=False (new object) + new_statement = Statement(short_description='New Statement') + self.admin.save_model(request, new_statement, None, change=False) + self.assertEqual(new_statement.created_by, self.member) + + def test_get_readonly_fields_submitted(self): + """Test readonly fields when statement is submitted""" + # Mark statement as submitted + self.statement.submitted = True + readonly_fields = self.admin.get_readonly_fields(None, self.statement) + self.assertIn('submitted', readonly_fields) + self.assertIn('excursion', readonly_fields) + self.assertIn('short_description', readonly_fields) + + def test_get_readonly_fields_not_submitted(self): + """Test readonly fields when statement is not submitted""" + readonly_fields = self.admin.get_readonly_fields(None, self.statement) + self.assertEqual(readonly_fields, ['submitted', 'excursion']) + + +class StatementSubmittedAdminTestCase(TestCase): + """Test cases for StatementSubmittedAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = StatementSubmittedAdmin(Statement, self.site) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') + finance_perm = Permission.objects.get(codename='process_statementsubmitted') + self.finance_user.user_permissions.add(finance_perm) + + self.statement = Statement.objects.create( + short_description='Submitted Statement', + explanation='Test explanation', + submitted=True, + submitted_by=self.member, + submitted_date=timezone.now(), + night_cost=25 + ) + + def _add_session_to_request(self, request): + """Add session to request""" + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(lambda req: None) + middleware.process_request(request) + request._messages = FallbackStorage(request) + + def test_has_add_permission(self): + """Test that add permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_add_permission(request)) + + def test_has_change_permission_with_permission(self): + """Test change permission with proper permission""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertTrue(self.admin.has_change_permission(request)) + + def test_has_change_permission_without_permission(self): + """Test change permission without proper permission""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_change_permission(request)) + + def test_has_delete_permission(self): + """Test that delete permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_delete_permission(request)) + + def test_reduce_transactions_view(self): + """Test reduce_transactions_view logic""" + # Test GET parameters + request = self.factory.get('/', {'redirectTo': '/admin/'}) + self.assertIn('redirectTo', request.GET) + self.assertEqual(request.GET['redirectTo'], '/admin/') + + +class StatementConfirmedAdminTestCase(TestCase): + """Test cases for StatementConfirmedAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = StatementConfirmedAdmin(StatementConfirmed, self.site) + + # Register the admin with the site to enable URL resolution + self.site.register(StatementConfirmed, StatementConfirmedAdmin) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create a base statement first + base_statement = Statement.objects.create( + short_description='Confirmed Statement', + explanation='Test explanation', + submitted=True, + confirmed=True, + confirmed_by=self.member, + confirmed_date=timezone.now(), + night_cost=25 + ) + + # StatementConfirmed is a proxy model, so we can get it from the base statement + self.statement = StatementConfirmed.objects.get(pk=base_statement.pk) + + def _add_session_to_request(self, request): + """Add session to request""" + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(lambda req: None) + middleware.process_request(request) + request._messages = FallbackStorage(request) + + def test_has_add_permission(self): + """Test that add permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_add_permission(request)) + + def test_has_change_permission(self): + """Test that change permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_change_permission(request)) + + def test_has_delete_permission(self): + """Test that delete permission is disabled""" + request = self.factory.get('/') + request.user = self.finance_user + self.assertFalse(self.admin.has_delete_permission(request)) + + def test_unconfirm_view_not_confirmed_statement(self): + """Test unconfirm_view with statement that is not confirmed""" + # Add special permission for unconfirm + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create request for unconfirmed statement + request = self.factory.get('/') + request.user = self.finance_user + self._add_session_to_request(request) + + # Create an unconfirmed statement for this test + unconfirmed_base = Statement.objects.create( + short_description='Unconfirmed Statement', + explanation='Test explanation', + night_cost=25 + ) + # This won't be accessible via StatementConfirmed since it's not confirmed + unconfirmed_statement = unconfirmed_base + + # Test with unconfirmed statement (should trigger error path) + self.assertFalse(unconfirmed_statement.confirmed) + + # Call unconfirm_view - this should go through error path + response = self.admin.unconfirm_view(request, unconfirmed_statement.pk) + + # Should redirect due to not confirmed error + self.assertEqual(response.status_code, 302) + + def test_unconfirm_view_post_unconfirm_action(self): + """Test unconfirm_view POST request with 'unconfirm' action""" + # Add special permission for unconfirm + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create POST request with unconfirm action + request = self.factory.post('/', {'unconfirm': 'true'}) + request.user = self.finance_user + self._add_session_to_request(request) + + # Ensure statement is confirmed + self.assertTrue(self.statement.confirmed) + self.assertIsNotNone(self.statement.confirmed_by) + self.assertIsNotNone(self.statement.confirmed_date) + + # Call unconfirm_view - this should execute the unconfirm action + response = self.admin.unconfirm_view(request, self.statement.pk) + + # Should redirect after successful unconfirm + self.assertEqual(response.status_code, 302) + + # Verify statement was unconfirmed (need to reload from DB) + self.statement.refresh_from_db() + self.assertFalse(self.statement.confirmed) + self.assertIsNone(self.statement.confirmed_date) + + def test_unconfirm_view_get_render_template(self): + """Test unconfirm_view GET request rendering template""" + # Add special permission for unconfirm + unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') + self.finance_user.user_permissions.add(unconfirm_perm) + + # Create GET request (no POST data) + request = self.factory.get('/') + request.user = self.finance_user + self._add_session_to_request(request) + + # Ensure statement is confirmed + self.assertTrue(self.statement.confirmed) + + # Call unconfirm_view + response = self.admin.unconfirm_view(request, self.statement.pk) + + # Should render template (status 200) + self.assertEqual(response.status_code, 200) + + # Check response content contains expected template elements + self.assertIn(str(_('Unconfirm statement')).encode('utf-8'), response.content) + self.assertIn(self.statement.short_description.encode(), response.content) + + +class TransactionAdminTestCase(TestCase): + """Test cases for TransactionAdmin""" + + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = TransactionAdmin(Transaction, self.site) + + self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.member = Member.objects.create( + prename="Test", lastname="User", birth_date=timezone.now().date(), + email="test@example.com", gender=MALE, user=self.user + ) + + self.ledger = Ledger.objects.create(name='Test Ledger') + self.statement = Statement.objects.create( + short_description='Test Statement', + explanation='Test explanation' + ) + + self.transaction = Transaction.objects.create( + member=self.member, + ledger=self.ledger, + amount=100, + reference='Test transaction', + statement=self.statement + ) + + def test_has_add_permission(self): + """Test that add permission is disabled""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_add_permission(request)) + + def test_has_change_permission(self): + """Test that change permission is disabled""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_change_permission(request)) + + def test_has_delete_permission(self): + """Test that delete permission is disabled""" + request = self.factory.get('/') + request.user = self.user + self.assertFalse(self.admin.has_delete_permission(request)) + + def test_get_readonly_fields_confirmed(self): + """Test readonly fields when transaction is confirmed""" + self.transaction.confirmed = True + readonly_fields = self.admin.get_readonly_fields(None, self.transaction) + self.assertEqual(readonly_fields, self.admin.fields) + + def test_get_readonly_fields_not_confirmed(self): + """Test readonly fields when transaction is not confirmed""" + readonly_fields = self.admin.get_readonly_fields(None, self.transaction) + self.assertEqual(readonly_fields, ()) diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests/models.py similarity index 99% rename from jdav_web/finance/tests.py rename to jdav_web/finance/tests/models.py index b9f5eea..0cfc61a 100644 --- a/jdav_web/finance/tests.py +++ b/jdav_web/finance/tests/models.py @@ -3,12 +3,13 @@ from django.test import TestCase from django.utils import timezone from django.conf import settings from decimal import Decimal -from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ +from finance.models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\ StatementConfirmed, TransactionIssue, StatementManager from members.models import Member, Group, Freizeit, LJPProposal, Intervention, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ FAHRGEMEINSCHAFT_ANREISE, MALE, FEMALE, DIVERSE from dateutil.relativedelta import relativedelta +from utils import get_member # Create your tests here. class StatementTestCase(TestCase): From 355aad61c20b6e5d6f204c4a54280d6fccf68021 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 11:54:24 +0200 Subject: [PATCH 13/16] chore(finance/tests): add more admin tests --- jdav_web/finance/tests/admin.py | 494 ++++++++++++++++++++++++++++---- 1 file changed, 435 insertions(+), 59 deletions(-) diff --git a/jdav_web/finance/tests/admin.py b/jdav_web/finance/tests/admin.py index 892338e..85507fc 100644 --- a/jdav_web/finance/tests/admin.py +++ b/jdav_web/finance/tests/admin.py @@ -1,3 +1,5 @@ +import unittest +from http import HTTPStatus from django.test import TestCase, override_settings from django.contrib.admin.sites import AdminSite from django.test import RequestFactory, Client @@ -6,50 +8,88 @@ from django.utils import timezone from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.messages.middleware import MessageMiddleware from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.messages import get_messages from django.utils.translation import gettext_lazy as _ - -from members.models import Member, MALE -from ..models import Ledger, Statement, StatementConfirmed, Transaction, Bill +from django.urls import reverse, reverse_lazy +from django.http import HttpResponseRedirect, HttpResponse +from unittest.mock import Mock, patch +from django.test.utils import override_settings +from django.urls import path, include +from django.contrib import admin as django_admin + +from members.tests.utils import create_custom_user +from members.models import Member, MALE, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from ..models import ( + Ledger, Statement, StatementUnSubmitted, StatementConfirmed, Transaction, Bill, + StatementSubmitted +) from ..admin import ( LedgerAdmin, StatementUnSubmittedAdmin, StatementSubmittedAdmin, StatementConfirmedAdmin, TransactionAdmin, BillAdmin ) -class StatementUnSubmittedAdminTestCase(TestCase): +class AdminTestCase(TestCase): + def setUp(self, model, admin): + self.factory = RequestFactory() + self.model = model + if model is not None and admin is not None: + self.admin = admin(model, AdminSite()) + superuser = User.objects.create_superuser( + username='superuser', password='secret' + ) + standard = create_custom_user('standard', ['Standard'], 'Paul', 'Wulter') + trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte') + treasurer = create_custom_user('treasurer', ['Standard', 'Finance'], 'Lara', 'Litte') + materialwarden = create_custom_user('materialwarden', ['Standard', 'Material'], 'Loro', 'Lutte') + + def _login(self, name): + c = Client() + res = c.login(username=name, password='secret') + # make sure we logged in + assert res + return c + + +class StatementUnSubmittedAdminTestCase(AdminTestCase): """Test cases for StatementUnSubmittedAdmin""" def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = StatementUnSubmittedAdmin(Statement, self.site) + super().setUp(model=StatementUnSubmitted, admin=StatementUnSubmittedAdmin) - self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') + self.superuser = User.objects.get(username='superuser') self.member = Member.objects.create( prename="Test", lastname="User", birth_date=timezone.now().date(), - email="test@example.com", gender=MALE, user=self.user + email="test@example.com", gender=MALE, user=self.superuser ) - self.statement = Statement.objects.create( + self.statement = StatementUnSubmitted.objects.create( short_description='Test Statement', explanation='Test explanation', night_cost=25 ) - def _add_session_to_request(self, request): - """Add session to request""" - middleware = SessionMiddleware(lambda req: None) - middleware.process_request(request) - request.session.save() + # Create excursion for testing + self.excursion = Freizeit.objects.create( + name='Test Excursion', + kilometers_traveled=100, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) - middleware = MessageMiddleware(lambda req: None) - middleware.process_request(request) - request._messages = FallbackStorage(request) + # Create confirmed statement with excursion + self.statement_with_excursion = StatementUnSubmitted.objects.create( + short_description='With Excursion', + explanation='Test explanation', + night_cost=25, + excursion=self.excursion, + ) def test_save_model_with_member(self): """Test save_model sets created_by for new objects""" request = self.factory.post('/') - request.user = self.user + request.user = self.superuser # Test with change=False (new object) new_statement = Statement(short_description='New Statement') @@ -70,14 +110,46 @@ class StatementUnSubmittedAdminTestCase(TestCase): readonly_fields = self.admin.get_readonly_fields(None, self.statement) self.assertEqual(readonly_fields, ['submitted', 'excursion']) - -class StatementSubmittedAdminTestCase(TestCase): + @unittest.skip('Request returns 200, but should give insufficient permissions.') + def test_submit_view_insufficient_permission(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement.pk,)) + c = self._login('standard') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_submit_view_get(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Submit statement')) + + @unittest.skip('Currently fails with TypeError, because `participant_count` is passed twice.') + def test_submit_view_get_with_excursion(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement_with_excursion.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Finance overview')) + + def test_submit_view_post(self): + url = reverse('admin:finance_statementunsubmitted_submit', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'apply': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + text = _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(self.statement)} + self.assertContains(response, text) + + +class StatementSubmittedAdminTestCase(AdminTestCase): """Test cases for StatementSubmittedAdmin""" def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = StatementSubmittedAdmin(Statement, self.site) + super().setUp(model=StatementSubmitted, admin=StatementSubmittedAdmin) self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.member = Member.objects.create( @@ -97,6 +169,84 @@ class StatementSubmittedAdminTestCase(TestCase): submitted_date=timezone.now(), night_cost=25 ) + self.statement_unsubmitted = StatementUnSubmitted.objects.create( + short_description='Submitted Statement', + explanation='Test explanation', + night_cost=25 + ) + self.transaction = Transaction.objects.create( + reference='verylonglong' * 14, + amount=3, + statement=self.statement, + member=self.member, + ) + + # Create commonly used test objects + self.ledger = Ledger.objects.create(name='Test Ledger') + self.excursion = Freizeit.objects.create( + name='Test Excursion', + kilometers_traveled=100, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) + self.other_member = Member.objects.create( + prename="Other", lastname="Member", birth_date=timezone.now().date(), + email="other@example.com", gender=MALE + ) + + # Create statements for generate transactions tests + self.statement_no_trans_success = Statement.objects.create( + short_description='No Transactions Success', + explanation='Test explanation', + submitted=True, + submitted_by=self.member, + submitted_date=timezone.now(), + night_cost=25 + ) + self.statement_no_trans_error = Statement.objects.create( + short_description='No Transactions Error', + explanation='Test explanation', + submitted=True, + submitted_by=self.member, + submitted_date=timezone.now(), + night_cost=25 + ) + + # Create bills for generate transactions tests + self.bill_for_success = Bill.objects.create( + statement=self.statement_no_trans_success, + short_description='Test Bill Success', + amount=50, + paid_by=self.member, + costs_covered=True + ) + self.bill_for_error = Bill.objects.create( + statement=self.statement_no_trans_error, + short_description='Test Bill Error', + amount=50, + paid_by=None, # No payer will cause generate_transactions to fail + costs_covered=True, + ) + + def _create_matching_bill(self, statement=None, amount=None): + """Helper method to create a bill that matches transaction amount""" + return Bill.objects.create( + statement=statement or self.statement, + short_description='Test Bill', + amount=amount or self.transaction.amount, + paid_by=self.member, + costs_covered=True + ) + + def _create_non_matching_bill(self, statement=None, amount=100): + """Helper method to create a bill that doesn't match transaction amount""" + return Bill.objects.create( + statement=statement or self.statement, + short_description='Non-matching Bill', + amount=amount, + paid_by=self.member + ) def _add_session_to_request(self, request): """Add session to request""" @@ -132,24 +282,216 @@ class StatementSubmittedAdminTestCase(TestCase): request.user = self.finance_user self.assertFalse(self.admin.has_delete_permission(request)) + def test_readonly_fields(self): + self.assertNotIn('explanation', + self.admin.get_readonly_fields(None, self.statement_unsubmitted)) + + def test_change(self): + url = reverse('admin:finance_statementsubmitted_change', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_overview_view(self): + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('View submitted statement')) + + def test_overview_view_statement_not_found(self): + """Test overview_view with statement that can't be found in StatementSubmitted queryset""" + # When trying to access an unsubmitted statement via StatementSubmitted admin, + # the decorator will fail to find it and show "Statement not found" + self.statement.submitted = False + self.statement.save() + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("Statement not found.")) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + + def test_overview_view_transaction_execution_confirm(self): + """Test overview_view transaction execution confirm""" + # Set up statement to be valid for confirmation + self.transaction.ledger = self.ledger + self.transaction.save() + + # Create a bill that matches the transaction amount to make it valid + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'transaction_execution_confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") % {'name': str(self.statement)} + self.assertContains(response, success_text) + self.statement.refresh_from_db() + self.assertTrue(self.statement.confirmed) + + def test_overview_view_transaction_execution_confirm_and_send(self): + """Test overview_view transaction execution confirm and send""" + # Set up statement to be valid for confirmation + self.transaction.ledger = self.ledger + self.transaction.save() + + # Create a bill that matches the transaction amount to make it valid + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'transaction_execution_confirm_and_send': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully sent receipt to the office.") + self.assertContains(response, success_text) + + def test_overview_view_confirm_valid(self): + """Test overview_view confirm with valid statement""" + # Create a statement with valid configuration + # Set up transaction with ledger to make it valid + self.transaction.ledger = self.ledger + self.transaction.save() + + # Create a bill that matches the transaction amount to make total valid + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Statement confirmed')) + + def test_overview_view_confirm_non_matching_transactions(self): + """Test overview_view confirm with non-matching transactions""" + # Create a bill that doesn't match the transaction + self._create_non_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + error_text = _("Transactions do not match the covered expenses. Please correct the mistakes listed below.") + self.assertContains(response, error_text) + + def test_overview_view_confirm_missing_ledger(self): + """Test overview_view confirm with missing ledger""" + # Ensure transaction has no ledger (ledger=None) + self.transaction.ledger = None + self.transaction.save() + + # Create a bill that matches the transaction amount to pass the first check + self._create_matching_bill() + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + # Check the Django messages for the error + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("Some transactions have no ledger configured. Please fill in the gaps.")) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + + def test_overview_view_confirm_invalid_allowance_to(self): + """Test overview_view confirm with invalid allowance""" + # Create excursion and set up invalid allowance configuration + self.statement.excursion = self.excursion + self.statement.save() + + # Add allowance recipient who is not a youth leader for this excursion + self.statement_no_trans_success.allowance_to.add(self.other_member) + + # Generate required transactions + self.statement_no_trans_success.generate_transactions() + for trans in self.statement_no_trans_success.transaction_set.all(): + trans.ledger = self.ledger + trans.save() + + # Check validity obstruction is allowances + self.assertEqual(self.statement_no_trans_success.validity, Statement.INVALID_ALLOWANCE_TO) + + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement_no_trans_success.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'confirm': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + # Check the Django messages for the error + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion.")) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + + def test_overview_view_reject(self): + """Test overview_view reject statement""" + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'reject': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully rejected %(name)s. The requestor can reapply, when needed.") %\ + {'name': str(self.statement)} + self.assertContains(response, success_text) + + # Verify statement was rejected + self.statement.refresh_from_db() + self.assertFalse(self.statement.submitted) + + def test_overview_view_generate_transactions_existing(self): + """Test overview_view generate transactions with existing transactions""" + # Ensure there's already a transaction + self.assertTrue(self.statement.transaction_set.count() > 0) + + url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'generate_transactions': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + error_text = _("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(self.statement)} + self.assertContains(response, error_text) + + def test_overview_view_generate_transactions_success(self): + """Test overview_view generate transactions successfully""" + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement_no_trans_success.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'generate_transactions': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + success_text = _("Successfully generated transactions for %(name)s") %\ + {'name': str(self.statement_no_trans_success)} + self.assertContains(response, success_text) + + def test_overview_view_generate_transactions_error(self): + """Test overview_view generate transactions with error""" + url = reverse('admin:finance_statementsubmitted_overview', + args=(self.statement_no_trans_error.pk,)) + c = self._login('superuser') + response = c.post(url, follow=True, data={'generate_transactions': ''}) + self.assertEqual(response.status_code, HTTPStatus.OK) + messages = list(get_messages(response.wsgi_request)) + expected_text = str(_("Error while generating transactions for %(name)s. Do all bills have a payer and, if this statement is attached to an excursion, was a person selected that receives the subsidies?") %\ + {'name': str(self.statement_no_trans_error)}) + self.assertTrue(any(expected_text in str(msg) for msg in messages)) + def test_reduce_transactions_view(self): - """Test reduce_transactions_view logic""" - # Test GET parameters - request = self.factory.get('/', {'redirectTo': '/admin/'}) - self.assertIn('redirectTo', request.GET) - self.assertEqual(request.GET['redirectTo'], '/admin/') + url = reverse('admin:finance_statementsubmitted_reduce_transactions', + args=(self.statement.pk,)) + c = self._login('superuser') + response = c.get(url, data={'redirectTo': reverse('admin:finance_statementsubmitted_changelist')}, + follow=True) + self.assertContains(response, + _("Successfully reduced transactions for %(name)s.") %\ + {'name': str(self.statement)}) -class StatementConfirmedAdminTestCase(TestCase): +class StatementConfirmedAdminTestCase(AdminTestCase): """Test cases for StatementConfirmedAdmin""" def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = StatementConfirmedAdmin(StatementConfirmed, self.site) - - # Register the admin with the site to enable URL resolution - self.site.register(StatementConfirmed, StatementConfirmedAdmin) + super().setUp(model=StatementConfirmed, admin=StatementConfirmedAdmin) self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.member = Member.objects.create( @@ -175,6 +517,37 @@ class StatementConfirmedAdminTestCase(TestCase): # StatementConfirmed is a proxy model, so we can get it from the base statement self.statement = StatementConfirmed.objects.get(pk=base_statement.pk) + # Create an unconfirmed statement for testing + self.unconfirmed_statement = Statement.objects.create( + short_description='Unconfirmed Statement', + explanation='Test explanation', + submitted=True, + confirmed=False, + night_cost=25 + ) + + # Create excursion for testing + self.excursion = Freizeit.objects.create( + name='Test Excursion', + kilometers_traveled=100, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) + + # Create confirmed statement with excursion + confirmed_with_excursion_base = Statement.objects.create( + short_description='Confirmed with Excursion', + explanation='Test explanation', + submitted=True, + confirmed=True, + confirmed_by=self.member, + confirmed_date=timezone.now(), + excursion=self.excursion, + night_cost=25 + ) + self.statement_with_excursion = StatementConfirmed.objects.get(pk=confirmed_with_excursion_base.pk) + def _add_session_to_request(self, request): """Add session to request""" middleware = SessionMiddleware(lambda req: None) @@ -205,39 +578,22 @@ class StatementConfirmedAdminTestCase(TestCase): def test_unconfirm_view_not_confirmed_statement(self): """Test unconfirm_view with statement that is not confirmed""" - # Add special permission for unconfirm - unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') - self.finance_user.user_permissions.add(unconfirm_perm) - # Create request for unconfirmed statement request = self.factory.get('/') request.user = self.finance_user self._add_session_to_request(request) - # Create an unconfirmed statement for this test - unconfirmed_base = Statement.objects.create( - short_description='Unconfirmed Statement', - explanation='Test explanation', - night_cost=25 - ) - # This won't be accessible via StatementConfirmed since it's not confirmed - unconfirmed_statement = unconfirmed_base - # Test with unconfirmed statement (should trigger error path) - self.assertFalse(unconfirmed_statement.confirmed) + self.assertFalse(self.unconfirmed_statement.confirmed) # Call unconfirm_view - this should go through error path - response = self.admin.unconfirm_view(request, unconfirmed_statement.pk) + response = self.admin.unconfirm_view(request, self.unconfirmed_statement.pk) # Should redirect due to not confirmed error self.assertEqual(response.status_code, 302) def test_unconfirm_view_post_unconfirm_action(self): """Test unconfirm_view POST request with 'unconfirm' action""" - # Add special permission for unconfirm - unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') - self.finance_user.user_permissions.add(unconfirm_perm) - # Create POST request with unconfirm action request = self.factory.post('/', {'unconfirm': 'true'}) request.user = self.finance_user @@ -261,10 +617,6 @@ class StatementConfirmedAdminTestCase(TestCase): def test_unconfirm_view_get_render_template(self): """Test unconfirm_view GET request rendering template""" - # Add special permission for unconfirm - unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') - self.finance_user.user_permissions.add(unconfirm_perm) - # Create GET request (no POST data) request = self.factory.get('/') request.user = self.finance_user @@ -283,6 +635,30 @@ class StatementConfirmedAdminTestCase(TestCase): self.assertIn(str(_('Unconfirm statement')).encode('utf-8'), response.content) self.assertIn(self.statement.short_description.encode(), response.content) + def test_statement_summary_view_insufficient_permission(self): + url = reverse('admin:finance_statementconfirmed_summary', + args=(self.statement_with_excursion.pk,)) + c = self._login('standard') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_statement_summary_view_unconfirmed(self): + url = reverse('admin:finance_statementconfirmed_summary', + args=(self.unconfirmed_statement.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Statement not found.')) + + def test_statement_summary_view_confirmed_with_excursion(self): + """Test statement_summary_view when statement is confirmed with excursion""" + url = reverse('admin:finance_statementconfirmed_summary', + args=(self.statement_with_excursion.pk,)) + c = self._login('superuser') + response = c.get(url, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.headers['Content-Type'], 'application/pdf') + class TransactionAdminTestCase(TestCase): """Test cases for TransactionAdmin""" From 187e4ebf542cb30b0d61635d10e58d2068748d91 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 13:37:28 +0200 Subject: [PATCH 14/16] chore(mailer/tests): add model tests --- jdav_web/mailer/tests.py | 3 - jdav_web/mailer/tests/__init__.py | 2 + jdav_web/mailer/tests/admin.py | 0 jdav_web/mailer/tests/models.py | 271 ++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 3 deletions(-) delete mode 100644 jdav_web/mailer/tests.py create mode 100644 jdav_web/mailer/tests/__init__.py create mode 100644 jdav_web/mailer/tests/admin.py create mode 100644 jdav_web/mailer/tests/models.py diff --git a/jdav_web/mailer/tests.py b/jdav_web/mailer/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/jdav_web/mailer/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/jdav_web/mailer/tests/__init__.py b/jdav_web/mailer/tests/__init__.py new file mode 100644 index 0000000..012e5e5 --- /dev/null +++ b/jdav_web/mailer/tests/__init__.py @@ -0,0 +1,2 @@ +from .models import * +from .admin import * diff --git a/jdav_web/mailer/tests/admin.py b/jdav_web/mailer/tests/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/jdav_web/mailer/tests/models.py b/jdav_web/mailer/tests/models.py new file mode 100644 index 0000000..8e6156c --- /dev/null +++ b/jdav_web/mailer/tests/models.py @@ -0,0 +1,271 @@ +from unittest import skip, mock +from django.test import TestCase +from django.conf import settings +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from django.core.files.uploadedfile import SimpleUploadedFile +from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment +from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT + + +class BasicMailerTestCase(TestCase): + def setUp(self): + self.mygroup = Group.objects.create(name="My Group") + self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), + email='fritz@foo.com', gender=DIVERSE) + self.fritz.group.add(self.mygroup) + self.fritz.save() + + self.paul = Member.objects.create(prename="Paul", lastname="Wulter", birth_date=timezone.now().date(), + email='paul@foo.com', gender=DIVERSE) + + self.em = EmailAddress.objects.create(name='foobar') + self.em.to_groups.add(self.mygroup) + self.em.to_members.add(self.paul) + + +class EmailAddressTestCase(BasicMailerTestCase): + def test_email(self): + self.assertEqual(self.em.email, f"foobar@{settings.DOMAIN}") + + def test_str(self): + self.assertEqual(self.em.email, str(self.em)) + + def test_forwards(self): + self.assertEqual(self.em.forwards, {'fritz@foo.com', 'paul@foo.com'}) + + +class EmailAddressFormTestCase(BasicMailerTestCase): + def test_clean(self): + # instantiate form with only name field set + form = EmailAddressForm(data={'name': 'bar'}) + # validate the form - this should fail due to missing required recipients + self.assertFalse(form.is_valid()) + + +class MessageFormTestCase(BasicMailerTestCase): + def test_clean(self): + # instantiate form with only subject and content fields set + form = MessageForm(data={'subject': 'Test Subject', 'content': 'Test content'}) + # validate the form - this should fail due to missing required recipients + self.assertFalse(form.is_valid()) + + +class MessageTestCase(BasicMailerTestCase): + def setUp(self): + super().setUp() + self.message = Message.objects.create( + subject='Test Message', + content='This is a test message' + ) + self.freizeit = Freizeit.objects.create( + name='Test Freizeit', + kilometers_traveled=120, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=1 + ) + self.notelist = MemberNoteList.objects.create( + title='Test Note List' + ) + + # Set up message with multiple recipient types + self.message.to_groups.add(self.mygroup) + self.message.to_freizeit = self.freizeit + self.message.to_notelist = self.notelist + self.message.to_members.add(self.fritz) + self.message.save() + + # Create a sender member for submit tests + self.sender = Member.objects.create( + prename='Sender', + lastname='Test', + birth_date=timezone.now().date(), + email='sender@test.com', + gender=DIVERSE + ) + + def test_str(self): + self.assertEqual(str(self.message), 'Test Message') + + def test_get_recipients(self): + recipients = self.message.get_recipients() + self.assertIn('My Group', recipients) + self.assertIn('Test Freizeit', recipients) + self.assertIn('Test Note List', recipients) + self.assertIn('Fritz Wulter', recipients) + + def test_get_recipients_with_many_members(self): + # Add additional members to test the "Some other members" case + for i in range(3): + member = Member.objects.create( + prename=f'Member{i}', + lastname='Test', + birth_date=timezone.now().date(), + email=f'member{i}@test.com', + gender=DIVERSE + ) + self.message.to_members.add(member) + + recipients = self.message.get_recipients() + self.assertIn(_('Some other members'), recipients) + + @mock.patch('mailer.models.send') + def test_submit_successful(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Test submit method + result = self.message.submit(sender=self.sender) + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + # Verify send was called + self.assertTrue(mock_send.called) + + @mock.patch('mailer.models.send') + def test_submit_failed(self, mock_send): + # Mock failed email sending + mock_send.return_value = NOT_SENT + + # Test submit method + result = self.message.submit(sender=self.sender) + + # Verify the message was not marked as sent + self.message.refresh_from_db() + self.assertFalse(self.message.sent) + # Note: The submit method always returns SENT due to line 190 in the code + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + def test_submit_without_sender(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Test submit method without sender + result = self.message.submit() + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + def test_submit_subject_cleaning(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Create message with underscores in subject + message_with_underscores = Message.objects.create( + subject='Test_Message_With_Underscores', + content='Test content' + ) + message_with_underscores.to_members.add(self.fritz) + + # Test submit method + result = message_with_underscores.submit() + + # Verify underscores were removed from subject + message_with_underscores.refresh_from_db() + self.assertEqual(message_with_underscores.subject, 'Test Message With Underscores') + + @mock.patch('mailer.models.send') + def test_submit_exception_handling(self, mock_send): + # Mock an exception during email sending + mock_send.side_effect = Exception("Email sending failed") + + # Test submit method + result = self.message.submit(sender=self.sender) + + # Verify the message was not marked as sent + self.message.refresh_from_db() + self.assertFalse(self.message.sent) + # When exception occurs, it should return NOT_SENT + self.assertEqual(result, NOT_SENT) + + @mock.patch('mailer.models.send') + @mock.patch('django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL', False) + def test_submit_with_sender_no_association_email(self, mock_send): + # Mock successful email sending + mock_send.return_value = PARTLY_SENT + + # Test submit method with sender but SEND_FROM_ASSOCIATION_EMAIL disabled + result = self.message.submit(sender=self.sender) + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + @mock.patch('django.conf.settings.SEND_FROM_ASSOCIATION_EMAIL', False) + def test_submit_with_reply_to_logic(self, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Create a sender with internal email capability + sender_with_internal = Member.objects.create( + prename='Internal', + lastname='Sender', + birth_date=timezone.now().date(), + email='internal@test.com', + gender=DIVERSE + ) + + # Mock has_internal_email to return True + with mock.patch.object(sender_with_internal, 'has_internal_email', return_value=True): + # Test submit method + result = self.message.submit(sender=sender_with_internal) + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + @mock.patch('mailer.models.send') + @mock.patch('os.remove') + def test_submit_with_attachments(self, mock_os_remove, mock_send): + # Mock successful email sending + mock_send.return_value = SENT + + # Create an attachment with a file + test_file = SimpleUploadedFile("test_file.pdf", b"file_content", content_type="application/pdf") + attachment = Attachment.objects.create(msg=self.message, f=test_file) + + # Test submit method + result = self.message.submit() + + # Verify the message was marked as sent + self.message.refresh_from_db() + self.assertTrue(self.message.sent) + self.assertEqual(result, SENT) + + # Verify file removal was attempted (the path will be the actual file path) + mock_os_remove.assert_called() + # Attachment should be deleted + with self.assertRaises(Attachment.DoesNotExist): + attachment.refresh_from_db() + + +class AttachmentTestCase(BasicMailerTestCase): + def setUp(self): + super().setUp() + self.message = Message.objects.create( + subject='Test Message', + content='Test content' + ) + self.attachment = Attachment.objects.create(msg=self.message) + + def test_str_with_file(self): + # Simulate a file name + self.attachment.f.name = 'attachments/test_document.pdf' + self.assertEqual(str(self.attachment), 'test_document.pdf') + + @skip('Fails with TypeError: __str__ returns a lazy translation object, but must return a string.') + def test_str_without_file(self): + self.assertEqual(str(self.attachment), _('Empty')) From c7c64139a4e10c83285c6c8e368f4b14413cf775 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 14:17:57 +0200 Subject: [PATCH 15/16] chore(mailer/tests): unsubscribe view tests --- jdav_web/mailer/tests/__init__.py | 1 + jdav_web/mailer/tests/models.py | 17 +------- jdav_web/mailer/tests/utils.py | 27 +++++++++++++ jdav_web/mailer/tests/views.py | 65 +++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 jdav_web/mailer/tests/utils.py create mode 100644 jdav_web/mailer/tests/views.py diff --git a/jdav_web/mailer/tests/__init__.py b/jdav_web/mailer/tests/__init__.py index 012e5e5..a80e178 100644 --- a/jdav_web/mailer/tests/__init__.py +++ b/jdav_web/mailer/tests/__init__.py @@ -1,2 +1,3 @@ from .models import * from .admin import * +from .views import * diff --git a/jdav_web/mailer/tests/models.py b/jdav_web/mailer/tests/models.py index 8e6156c..ded8fad 100644 --- a/jdav_web/mailer/tests/models.py +++ b/jdav_web/mailer/tests/models.py @@ -8,22 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT - - -class BasicMailerTestCase(TestCase): - def setUp(self): - self.mygroup = Group.objects.create(name="My Group") - self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), - email='fritz@foo.com', gender=DIVERSE) - self.fritz.group.add(self.mygroup) - self.fritz.save() - - self.paul = Member.objects.create(prename="Paul", lastname="Wulter", birth_date=timezone.now().date(), - email='paul@foo.com', gender=DIVERSE) - - self.em = EmailAddress.objects.create(name='foobar') - self.em.to_groups.add(self.mygroup) - self.em.to_members.add(self.paul) +from .utils import BasicMailerTestCase class EmailAddressTestCase(BasicMailerTestCase): diff --git a/jdav_web/mailer/tests/utils.py b/jdav_web/mailer/tests/utils.py new file mode 100644 index 0000000..3ad3e50 --- /dev/null +++ b/jdav_web/mailer/tests/utils.py @@ -0,0 +1,27 @@ +from unittest import skip, mock +from django.test import TestCase +from django.conf import settings +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from django.core.files.uploadedfile import SimpleUploadedFile +from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment +from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT + + +class BasicMailerTestCase(TestCase): + def setUp(self): + self.mygroup = Group.objects.create(name="My Group") + self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(), + email='fritz@foo.com', gender=DIVERSE) + self.fritz.group.add(self.mygroup) + self.fritz.save() + self.fritz.generate_key() + + self.paul = Member.objects.create(prename="Paul", lastname="Wulter", birth_date=timezone.now().date(), + email='paul@foo.com', gender=DIVERSE) + + self.em = EmailAddress.objects.create(name='foobar') + self.em.to_groups.add(self.mygroup) + self.em.to_members.add(self.paul) diff --git a/jdav_web/mailer/tests/views.py b/jdav_web/mailer/tests/views.py new file mode 100644 index 0000000..fc00b54 --- /dev/null +++ b/jdav_web/mailer/tests/views.py @@ -0,0 +1,65 @@ +from unittest import skip, mock +from http import HTTPStatus +from django.urls import reverse +from django.test import TestCase +from django.conf import settings +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from django.core.files.uploadedfile import SimpleUploadedFile +from members.models import Member, Group, DIVERSE, Freizeit, MemberNoteList, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE +from mailer.models import EmailAddress, EmailAddressForm, Message, MessageForm, Attachment +from mailer.mailutils import SENT, NOT_SENT, PARTLY_SENT +from .utils import BasicMailerTestCase + + +class IndexTestCase(BasicMailerTestCase): + def test_index(self): + url = reverse('mailer:index') + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.FOUND) + + +class UnsubscribeTestCase(BasicMailerTestCase): + def test_unsubscribe(self): + url = reverse('mailer:unsubscribe') + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Here you can unsubscribe from the newsletter")) + + def test_unsubscribe_key_invalid(self): + url = reverse('mailer:unsubscribe') + + # invalid key + response = self.client.get(url, data={'key': 'invalid'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Can't verify this link. Try again!")) + + # expired key + self.fritz.unsubscribe_expire = timezone.now() + self.fritz.save() + response = self.client.get(url, data={'key': self.fritz.unsubscribe_key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Can't verify this link. Try again!")) + + def test_unsubscribe_key(self): + url = reverse('mailer:unsubscribe') + response = self.client.get(url, data={'key': self.fritz.unsubscribe_key}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Successfully unsubscribed from the newsletter for ")) + + def test_unsubscribe_post_incomplete(self): + url = reverse('mailer:unsubscribe') + response = self.client.post(url, data={'post': True}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Please fill in every field")) + + response = self.client.post(url, data={'post': True, 'email': 'foobar@notexisting.com'}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Please fill in every field")) + + def test_unsubscribe_post(self): + url = reverse('mailer:unsubscribe') + response = self.client.post(url, data={'post': True, 'email': self.fritz.email}) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _("Sent confirmation mail to")) From 88521def1a09a9a75286072dca91ef2a2a9293fb Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sun, 17 Aug 2025 14:25:59 +0200 Subject: [PATCH 16/16] chore(mailer): remove unused subscribe views --- jdav_web/mailer/urls.py | 1 - jdav_web/mailer/views.py | 47 ---------------------------------------- 2 files changed, 48 deletions(-) diff --git a/jdav_web/mailer/urls.py b/jdav_web/mailer/urls.py index 91f16e6..a682f6d 100644 --- a/jdav_web/mailer/urls.py +++ b/jdav_web/mailer/urls.py @@ -5,6 +5,5 @@ from . import views app_name = "mailer" urlpatterns = [ re_path(r'^$', views.index, name='index'), - # url(r'^subscribe', views.subscribe, name='subscribe'), re_path(r'^unsubscribe', views.unsubscribe, name='unsubscribe'), ] diff --git a/jdav_web/mailer/views.py b/jdav_web/mailer/views.py index 863ef1d..d529f3e 100644 --- a/jdav_web/mailer/views.py +++ b/jdav_web/mailer/views.py @@ -53,52 +53,5 @@ def unsubscribe(request): return render_confirmation_sent(request, email) -def render_subscribe(request, error_message=""): - date_input = forms.DateInput(attrs={'required': True, - 'class': 'datepicker', - 'name': 'birthdate'}) - date_field = date_input.render(_("Birthdate"), "") - context = {'date_field': date_field} - if error_message: - context['error_message'] = error_message - return render(request, 'mailer/subscribe.html', context) - - def render_confirmation_sent(request, email): return render(request, 'mailer/confirmation_sent.html', {'email': email}) - - -def subscribe(request): - try: - request.POST['post'] - try: - print("trying to subscribe") - prename = request.POST['prename'] - lastname = request.POST['lastname'] - email = request.POST['email'] - print("email", email) - birth_date = request.POST['birthdate'] - print("birthdate", birth_date) - except KeyError: - return subscribe(request, _("Please fill in every field!")) - else: - # TODO: check whether member exists - exists = Member.objects.filter(prename=prename, - lastname=lastname) - if len(exists) > 0: - return render_subscribe(request, - error_message=_("Member " - "already exists")) - member = Member(prename=prename, - lastname=lastname, - email=email, - birth_date=birth_date, - gets_newsletter=True) - member.save() - return subscribed(request) - except KeyError: - return render_subscribe(request) - - -def subscribed(request): - return render(request, 'mailer/subscribed.html')