From ce466671f28c358494a60763f23d6af2f5ac9265 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 18 Jan 2025 16:20:44 +0100 Subject: [PATCH 1/7] members/waitinglist: fix queryset based age calculation --- jdav_web/members/admin.py | 6 ++++- jdav_web/members/tests.py | 49 ++++++++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index ae3ed70..71c7257 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -683,7 +683,11 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): now = timezone.now() age_expr = ExpressionWrapper( Case( - When(birth_date__month__gte=now.month, birth_date__day__gt=now.day, then=now.year - F('birth_date__year') - 1), + # if the month of the birth date has not yet passed, subtract one year + When(birth_date__month__gt=now.month, then=now.year - F('birth_date__year') - 1), + # if it is the month of the birth date but the day has not yet passed, subtract one year + When(birth_date__month=now.month, birth_date__day__gt=now.day, then=now.year - F('birth_date__year') - 1), + # otherwise return the difference in years default=now.year - F('birth_date__year'), ), output_field=IntegerField() diff --git a/jdav_web/members/tests.py b/jdav_web/members/tests.py index 440446f..9c35f57 100644 --- a/jdav_web/members/tests.py +++ b/jdav_web/members/tests.py @@ -6,14 +6,15 @@ from django.test import TestCase, Client, RequestFactory from django.utils import timezone, translation from django.conf import settings from django.urls import reverse -from unittest import skip +from unittest import skip, mock from .models import Member, Group, PermissionMember, PermissionGroup, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE,\ - MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact,\ + MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\ DIVERSE, MALE, FEMALE +from .admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin from django.db import connection from django.db.migrations.executor import MigrationExecutor - -from .admin import FreizeitAdmin +import random +import datetime def create_custom_user(username, groups, prename, lastname): @@ -183,12 +184,12 @@ class PDFTestCase(TestCase): class AdminTestCase(TestCase): - def setUp(self, model): + def setUp(self, model, admin): self.factory = RequestFactory() self.model = model - if model is not None: - self.admin = FreizeitAdmin(model, AdminSite()) - User.objects.create_superuser( + 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') @@ -237,7 +238,7 @@ class AdminTestCase(TestCase): class PermissionTestCase(AdminTestCase): def setUp(self): - super().setUp(model=None) + super().setUp(model=None, admin=None) def test_standard_permissions(self): u = User.objects.get(username='standard') @@ -258,7 +259,7 @@ class PermissionTestCase(AdminTestCase): class MemberAdminTestCase(AdminTestCase): def setUp(self): - super().setUp(model=Member) + super().setUp(model=Member, admin=MemberAdmin) cool_kids = Group.objects.get(name='cool kids') super_kids = Group.objects.get(name='super kids') mega_kids = Group.objects.create(name='mega kids') @@ -384,7 +385,7 @@ class MemberAdminTestCase(AdminTestCase): class FreizeitAdminTestCase(AdminTestCase): def setUp(self): - super().setUp(model=Freizeit) + super().setUp(model=Freizeit, admin=FreizeitAdmin) ex = Freizeit.objects.create(name='Wild trip', kilometers_traveled=120, tour_type=GEMEINSCHAFTS_TOUR, tour_approach=MUSKELKRAFT_ANREISE, @@ -461,6 +462,32 @@ class FreizeitAdminTestCase(AdminTestCase): self.assertQuerysetEqual(queryset, Member.objects.none()) +class MemberWaitingListAdminTestCase(AdminTestCase): + def setUp(self): + super().setUp(model=MemberWaitingList, admin=MemberWaitingListAdmin) + for i in range(10): + day = random.randint(1, 28) + month = random.randint(1, 12) + year = random.randint(1900, timezone.now().year) + ex = MemberWaitingList.objects.create(prename='Peter {}'.format(i), + lastname='Puter', + birth_date=datetime.date(year, month, day), + email=settings.TEST_MAIL, + gender=FEMALE) + + def test_age_eq_birth_date_delta(self): + u = User.objects.get(username='superuser') + url = reverse('admin:members_memberwaitinglist_changelist') + request = self.factory.get(url) + request.user = u + queryset = self.admin.get_queryset(request) + today = timezone.now().date() + + for m in queryset: + self.assertEqual(m.birth_date_delta, m.age(), + msg='Queryset based age calculation differs from python based age calculation for birth date {birth_date} compared to {today}.'.format(birth_date=m.birth_date, today=today)) + + class MailConfirmationTestCase(BasicMemberTestCase): def setUp(self): super().setUp() From 8b932461b768a7108b8114e6a1d3dba2833bc610 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 18 Jan 2025 20:21:20 +0100 Subject: [PATCH 2/7] finance/statement: add selection fields for allowance and subsidies In the statement tab on excursions, add two new fields to select to which youth leaders the allowance and subsidies should be paid. The fields are checked for validity based on the number of approved youth leaders. Also add a new field on excursions to allow for additional approved youth leaders. The new fields are not yet used in the statement confirmation process. --- .../finance/locale/de/LC_MESSAGES/django.po | 27 +++++++++- ...6_statement_add_allowance_to_subsidy_to.py | 26 ++++++++++ jdav_web/finance/models.py | 19 ++++--- jdav_web/members/admin.py | 52 ++++++++++++++++++- .../members/locale/de/LC_MESSAGES/django.po | 47 +++++++++++++++-- ...izeit_approved_extra_youth_leader_count.py | 18 +++++++ jdav_web/members/models.py | 16 ++++++ 7 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 jdav_web/finance/migrations/0006_statement_add_allowance_to_subsidy_to.py create mode 100644 jdav_web/members/migrations/0033_freizeit_approved_extra_youth_leader_count.py diff --git a/jdav_web/finance/locale/de/LC_MESSAGES/django.po b/jdav_web/finance/locale/de/LC_MESSAGES/django.po index 6c85b0f..8738751 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-01-01 21:48+0100\n" +"POT-Creation-Date: 2025-01-18 20:19+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -157,6 +157,31 @@ msgstr "Erklärung" msgid "Associated excursion" msgstr "Zugehörige Ausfahrt" +#: finance/models.py +msgid "Pay allowance to" +msgstr "Aufwandsentschädigung auszahlen an" + +#: finance/models.py +msgid "" +"The youth leaders to which an allowance should be paid. The count must match " +"the number of permitted youth leaders." +msgstr "" +"Die Jugendleiter*innen an die eine Aufwandsentschädigung ausgezahlt werden " +"soll. Die Anzahl muss mit der Anzahl an zugelassenen Jugendleiter*innen " +"übereinstimmen. " + +#: finance/models.py +msgid "Pay subsidy to" +msgstr "Übernachtungs- und Fahrtkostenzuschüsse auszahlen an" + +#: finance/models.py +msgid "" +"The person that should receive the subsidy for night and travel costs. " +"Typically the person who paid for them." +msgstr "" +"Die Person, die die Übernachtungs- und Fahrtkostenzuschüsse erhalten soll. " +"Dies ist in der Regel die Person, die sie bezahlt hat." + #: finance/models.py msgid "Price per night" msgstr "Preis pro Nacht" diff --git a/jdav_web/finance/migrations/0006_statement_add_allowance_to_subsidy_to.py b/jdav_web/finance/migrations/0006_statement_add_allowance_to_subsidy_to.py new file mode 100644 index 0000000..df2d124 --- /dev/null +++ b/jdav_web/finance/migrations/0006_statement_add_allowance_to_subsidy_to.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.1 on 2025-01-18 19:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0032_member_upload_registration_form_key'), + ('members', '0033_freizeit_approved_extra_youth_leader_count'), + ('finance', '0005_alter_bill_proof'), + ] + + operations = [ + migrations.AddField( + model_name='statement', + name='allowance_to', + field=models.ManyToManyField(help_text='The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.', related_name='receives_allowance_for_statements', to='members.Member', verbose_name='Pay allowance to'), + ), + migrations.AddField( + model_name='statement', + name='subsidy_to', + field=models.ForeignKey(help_text='The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receives_subsidy_for_statements', to='members.member', verbose_name='Pay subsidy to'), + ), + ] diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index d27b74f..ed437e7 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -5,6 +5,7 @@ from django.utils import timezone from .rules import is_creator, not_submitted, leads_excursion from members.rules import is_leader, statement_not_submitted +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Sum from django.utils.translation import gettext_lazy as _ @@ -58,6 +59,15 @@ class Statement(CommonModel): null=True, on_delete=models.SET_NULL) + allowance_to = models.ManyToManyField(Member, verbose_name=_('Pay allowance to'), + related_name='receives_allowance_for_statements', + help_text=_('The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.')) + subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'), + null=True, + on_delete=models.SET_NULL, + related_name='receives_subsidy_for_statements', + help_text=_('The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.')) + night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5) submitted = models.BooleanField(verbose_name=_('Submitted'), default=False) @@ -307,15 +317,8 @@ class Statement(CommonModel): are refinanced though.""" if self.excursion is None: return 0 - - #raw_staff_count = self.excursion.jugendleiter.count() - participant_count = self.excursion.participant_count - if participant_count < 4: - return 0 - elif 4 <= participant_count <= 7: - return 2 else: - return 2 + math.ceil((participant_count - 7) / 7) + return self.excursion.approved_staff_count @property def total(self): diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 71c7257..6cb8164 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -836,13 +836,62 @@ class BillOnExcursionInline(CommonAdminInlineMixin, admin.TabularInline): } +class StatementOnListForm(forms.ModelForm): + """ + Form to edit a statement attached to an excursion. This is used in an inline on + the excursion admin. + """ + def __init__(self, *args, **kwargs): + excursion = kwargs.pop('parent_obj') + super(StatementOnListForm, self).__init__(*args, **kwargs) + # only allow youth leaders of this excursion to be selected as recipients + # of subsidies and allowance + self.fields['allowance_to'].queryset = excursion.jugendleiter.all() + self.fields['subsidy_to'].queryset = excursion.jugendleiter.all() + + class Meta: + model = Statement + fields = ['night_cost', 'allowance_to', 'subsidy_to'] + + def clean(self): + """Check if the `allowance_to` and `subsidy_to` fields are compatible with + the total number of approved youth leaders.""" + allowance_to = self.cleaned_data.get('allowance_to') + excursion = self.cleaned_data.get('excursion') + if allowance_to is None: + return + if allowance_to.count() > excursion.approved_staff_count: + raise ValidationError({ + 'allowance_to': _("This excursion only has up to %(approved_count)s approved youth leaders, but you listed %(entered_count)s." % {'approved_count': str(excursion.approved_staff_count), + 'entered_count': str(allowance_to.count())}), + }) + if allowance_to.count() < min(excursion.approved_staff_count, excursion.jugendleiter.count()): + raise ValidationError({ + 'allowance_to': _("This excursion has %(approved_count)s approved youth leaders, but you listed only %(entered_count)s." % {'approved_count': str(excursion.approved_staff_count), + 'entered_count': str(allowance_to.count())}) + }) + + class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedInline): model = Statement extra = 1 description = _('Please list here all expenses in relation with this excursion and upload relevant bills. These have to be permanently stored for the application of LJP contributions. The short descriptions are used in the seminar report cost overview (possible descriptions are e.g. food, material, etc.).') sortable_options = [] - fields = ['night_cost'] + fields = ['night_cost', 'allowance_to', 'subsidy_to'] inlines = [BillOnExcursionInline] + form = StatementOnListForm + + def get_formset(self, request, obj=None, **kwargs): + BaseFormSet = kwargs.pop('formset', self.formset) + + class CustomFormSet(BaseFormSet): + def get_form_kwargs(self, index): + kwargs = super().get_form_kwargs(index) + kwargs['parent_obj'] = obj + return kwargs + + kwargs['formset'] = CustomFormSet + return super().get_formset(request, obj, **kwargs) class InterventionOnLJPInline(CommonAdminInlineMixin, admin.TabularInline): @@ -943,6 +992,7 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): fieldsets = ( (None, { 'fields': ('name', 'place', 'destination', 'date', 'end', 'description', 'groups', 'jugendleiter', + 'approved_extra_youth_leader_count', 'tour_type', 'tour_approach', 'kilometers_traveled', 'activity', 'difficulty'), 'description': _('General information on your excursion. These are partly relevant for the amount of financial compensation (means of transport, travel distance, etc.).') }), diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 9a00530..b43fbfb 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-01-01 21:39+0100\n" +"POT-Creation-Date: 2025-01-18 20:19+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -187,10 +187,18 @@ msgstr "Gruppe" msgid "Invitation text" msgstr "Einladungstext" +#: members/admin.py +msgid "Age" +msgstr "Alter" + #: members/admin.py msgid "Pending group invitation for group" msgstr "Ausstehende Gruppeneinladung für Gruppe" +#: members/admin.py members/models.py +msgid "age" +msgstr "Alter" + #: members/admin.py #, python-format msgid "Successfully asked %(name)s to confirm their waiting status." @@ -251,6 +259,24 @@ msgstr "Art der Tour" msgid "Means of transportation" msgstr "Verkehrsmittel" +#: members/admin.py +#, python-format +msgid "" +"This excursion only has up to %(approved_count)s approved youth leaders, but " +"you listed %(entered_count)s." +msgstr "" +"Diese Ausfahrt hat nur bis zu %(approved_count)s zugelassene " +"Jugendleiter*innen, aber du hast %(entered_count)s eingetragen." + +#: members/admin.py +#, python-format +msgid "" +"This excursion has %(approved_count)s approved youth leaders, but you listed " +"only %(entered_count)s." +msgstr "" +"Diese Ausfahrt hat %(approved_count)s zugelassene Jugendleiter*innen, aber " +"du hast nur %(entered_count)s eingetragen." + #: members/admin.py msgid "" "Please list here all expenses in relation with this excursion and upload " @@ -496,10 +522,6 @@ msgstr "Gender" msgid "comments" msgstr "Kommentare" -#: members/models.py -msgid "age" -msgstr "Alter" - #: members/models.py msgid "Alternative email confirmed" msgstr "Alternative E-Mail Adresse bestätigt" @@ -785,6 +807,21 @@ msgstr "Ende" msgid "Groups" msgstr "Gruppen" +#: members/models.py +msgid "Number of additional approved youth leaders" +msgstr "Anzahl zusätzlich genehmigter Jugendleiter*innen" + +#: members/models.py +msgid "" +"The number of approved youth leaders per excursion is determined by the " +"number of participants. In special circumstances, e.g. in case of a " +"technically demanding excursion, more youth leaders may be approved." +msgstr "" +"Die Anzahl der genehmigten Jugendleiter*innen pro Ausfahrt wird " +"grundsätzlich durch die Anzahl der Teilnehmer*innen festgelegt. In " +"besonderen Fällen, zum Beispiel bei einer fachlich herausfordernden " +"Ausfahrt, können zusätzliche Jugendleiter*innen genehmigt werden." + #: members/models.py msgid "Kilometers traveled" msgstr "Fahrstrecke in Kilometer" diff --git a/jdav_web/members/migrations/0033_freizeit_approved_extra_youth_leader_count.py b/jdav_web/members/migrations/0033_freizeit_approved_extra_youth_leader_count.py new file mode 100644 index 0000000..5d7d9c2 --- /dev/null +++ b/jdav_web/members/migrations/0033_freizeit_approved_extra_youth_leader_count.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.1 on 2025-01-18 18:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0032_member_upload_registration_form_key'), + ] + + operations = [ + migrations.AddField( + model_name='freizeit', + name='approved_extra_youth_leader_count', + field=models.PositiveIntegerField(default=0, help_text='The number of approved youth leaders per excursion is determined by the number of participants. In special circumstances, e.g. in case of a technically demanding excursion, more youth leaders may be approved.', verbose_name='Number of additional approved youth leaders'), + ), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 4bef718..58adb57 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -1048,6 +1048,9 @@ class Freizeit(CommonModel): # comment = models.TextField(_('Comments'), default='', blank=True) groups = models.ManyToManyField(Group, verbose_name=_('Groups')) jugendleiter = models.ManyToManyField(Member) + approved_extra_youth_leader_count = models.PositiveIntegerField(verbose_name=_('Number of additional approved youth leaders'), + default=0, + help_text=_('The number of approved youth leaders per excursion is determined by the number of participants. In special circumstances, e.g. in case of a technically demanding excursion, more youth leaders may be approved.')) tour_type_choices = ((GEMEINSCHAFTS_TOUR, 'Gemeinschaftstour'), (FUEHRUNGS_TOUR, 'Führungstour'), (AUSBILDUNGS_TOUR, 'Ausbildung')) @@ -1134,6 +1137,19 @@ class Freizeit(CommonModel): jls = set(self.jugendleiter.distinct()) return len(ps - jls) + @property + def approved_staff_count(self): + """Number of approved youth leaders for this excursion. The base number is calculated + from the participant count. To this, the number of additional approved youth leaders is added.""" + participant_count = self.participant_count + if participant_count < 4: + base_count = 0 + elif 4 <= participant_count <= 7: + base_count = 2 + else: + base_count = 2 + math.ceil((participant_count - 7) / 7) + return base_count + self.approved_extra_youth_leader_count + @property def ljp_participant_count(self): ps = set(map(lambda x: x.member, self.membersonlist.distinct())) From 17bf6e818659287602d44f8c9e27ce2ad72edfc7 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 18 Jan 2025 21:20:41 +0100 Subject: [PATCH 3/7] members/admin: fix incorrect syntax in translation string --- jdav_web/members/admin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 6cb8164..5311c40 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -862,13 +862,13 @@ class StatementOnListForm(forms.ModelForm): return if allowance_to.count() > excursion.approved_staff_count: raise ValidationError({ - 'allowance_to': _("This excursion only has up to %(approved_count)s approved youth leaders, but you listed %(entered_count)s." % {'approved_count': str(excursion.approved_staff_count), - 'entered_count': str(allowance_to.count())}), + 'allowance_to': _("This excursion only has up to %(approved_count)s approved youth leaders, but you listed %(entered_count)s.") % {'approved_count': str(excursion.approved_staff_count), + 'entered_count': str(allowance_to.count())}, }) if allowance_to.count() < min(excursion.approved_staff_count, excursion.jugendleiter.count()): raise ValidationError({ - 'allowance_to': _("This excursion has %(approved_count)s approved youth leaders, but you listed only %(entered_count)s." % {'approved_count': str(excursion.approved_staff_count), - 'entered_count': str(allowance_to.count())}) + 'allowance_to': _("This excursion has %(approved_count)s approved youth leaders, but you listed only %(entered_count)s.") % {'approved_count': str(excursion.approved_staff_count), + 'entered_count': str(allowance_to.count())}, }) From 5d0aa18ddef32214cf4b53728e3154ddae561a39 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 18 Jan 2025 22:48:25 +0100 Subject: [PATCH 4/7] finance: adapt to allowance_to and subsidy_to Use the new `allowance_to` and `subsidy_to` fields in statement validation. The night and travel costs have to be transferred to the person listed as `subsidy_to`, the allowance has to be paid to the persons listed in `allowance_to`. --- jdav_web/finance/admin.py | 8 +++ .../finance/locale/de/LC_MESSAGES/django.po | 47 +++++++++++++-- jdav_web/finance/models.py | 58 ++++++++++++++++--- .../admin/overview_submitted_statement.html | 19 ++++++ 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 463e552..d9fae6f 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -206,6 +206,14 @@ class StatementSubmittedAdmin(admin.ModelAdmin): _("Some transactions have no ledger configured. Please fill in the gaps.") % {'name': str(statement)}) return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + elif res == Statement.INVALID_ALLOWANCE_TO: + messages.error(request, + _("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion.")) + return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + elif res == Statement.INVALID_TOTAL: + messages.error(request, + _("The calculated total amount does not match the sum of all transactions. This is most likely a bug.")) + return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) if "reject" in request.POST: diff --git a/jdav_web/finance/locale/de/LC_MESSAGES/django.po b/jdav_web/finance/locale/de/LC_MESSAGES/django.po index 8738751..3c6ffaf 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-01-18 20:19+0100\n" +"POT-Creation-Date: 2025-01-18 22:42+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -74,6 +74,22 @@ msgid "Some transactions have no ledger configured. Please fill in the gaps." msgstr "" "Manche Überweisungen haben kein Geldtopf eingestellt. Bitte trage das nach." +#: finance/admin.py +msgid "" +"The configured recipients for the allowance don't match the regulations. " +"Please correct this on the excursion." +msgstr "" +"Die ausgewählten Empfänger*innen der Aufwandsentschädigungen entsprechen " +"nicht den Regularien. Bitte korrigiere das in der Ausfahrt." + +#: finance/admin.py +msgid "" +"The calculated total amount does not match the sum of all transactions. This " +"is most likely a bug." +msgstr "" +"Der berechnete Gesamtbetrag stimmt nicht mit der Summe aller Überweisungen " +"überein. Das ist höchstwahrscheinlich ein Fehler in der Implementierung." + #: finance/admin.py #, python-format msgid "Successfully rejected %(name)s. The requestor can reapply, when needed." @@ -233,8 +249,13 @@ msgstr "Bereit zur Abwicklung" #: finance/models.py #, python-format -msgid "Compensation for %(excu)s" -msgstr "Entschädigung für %(excu)s" +msgid "Allowance for %(excu)s" +msgstr "Aufwandsentschädigung für %(excu)s" + +#: finance/models.py +#, python-format +msgid "Night and travel costs for %(excu)s" +msgstr "Übernachtungs- und Fahrtkosten für %(excu)s" #: finance/models.py finance/templates/admin/overview_submitted_statement.html msgid "Total" @@ -386,8 +407,8 @@ msgstr "Ausfahrt" #, python-format msgid "This excursion featured %(staff_count)s youth leader(s), each costing" msgstr "" -"Diese Ausfahrt hatte %(staff_count)s Jugendleiter*innen. Auf jede*n " -"entfallen die folgenden Kosten:" +"Diese Ausfahrt hatte %(staff_count)s genehmigte Jugendleiter*innen. Auf " +"jede*n entfallen die folgenden Kosten:" #: finance/templates/admin/overview_submitted_statement.html #, python-format @@ -425,6 +446,22 @@ msgstr "" "Insgesamt sind das Kosten von %(total_per_yl)s€ mal %(staff_count)s, " "insgesamt also %(total_staff)s€." +#: finance/templates/admin/overview_submitted_statement.html +#, python-format +msgid "The allowance of %(allowance_per_yl)s€ per person should be paid to:" +msgstr "" +"Die Aufwandsentschädigung von %(allowance_per_yl)s€ pro Person soll " +"ausgezahlt werden an:" + +#: finance/templates/admin/overview_submitted_statement.html +#, python-format +msgid "" +"The subsidies for night and transportation costs of %(total_subsidies)s€ " +"should be paid to:" +msgstr "" +"Die Zuschüsse für Übernachtungs- und Fahrtkosten von %(total_subsidies)s€ " +"sollen ausgezahlt werden an:" + #: finance/templates/admin/overview_submitted_statement.html #, python-format msgid "This results in a total amount of %(total)s€" diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index ed437e7..81e62b0 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -47,7 +47,7 @@ class StatementManager(models.Manager): class Statement(CommonModel): - MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, VALID = 0, 1, 2 + MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = 0, 1, 2, 3, 4 short_description = models.CharField(verbose_name=_('Short description'), max_length=30, @@ -123,7 +123,9 @@ class Statement(CommonModel): needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by] if self.excursion is not None: - needed_paiments.extend([(yl, self.real_per_yl) for yl in self.excursion.jugendleiter.all()]) + needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()]) + if self.subsidy_to: + needed_paiments.append((self.subsidy_to, self.total_subsidies)) needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk) target = dict(map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0]))) @@ -157,10 +159,25 @@ class Statement(CommonModel): def transactions_match_expenses(self): return len(self.transaction_issues) == 0 - def is_valid(self): - return self.ledgers_configured and self.transactions_match_expenses - is_valid.boolean = True - is_valid.short_description = _('Ready to confirm') + @property + def allowance_to_valid(self): + """Checks if the configured `allowance_to` field matches the regulations.""" + if self.allowance_to.count() != self.real_staff_count: + return False + if self.excursion is not None: + yls = self.excursion.jugendleiter.all() + for yl in self.allowance_to.all(): + if yl not in yls: + return False + return True + + @property + def total_valid(self): + """Checks if the calculated total agrees with the total amount of all transactions.""" + total_transactions = 0 + for transaction in self.transaction_set.all(): + total_transactions += transaction.amount + return self.total == total_transactions @property def validity(self): @@ -168,9 +185,18 @@ class Statement(CommonModel): return Statement.NON_MATCHING_TRANSACTIONS if not self.ledgers_configured: return Statement.MISSING_LEDGER + if not self.allowance_to_valid: + return Statement.INVALID_ALLOWANCE_TO + if not self.total_valid: + return Statement.INVALID_TOTAL else: return Statement.VALID + def is_valid(self): + return self.validity == Statement.VALID + is_valid.boolean = True + is_valid.short_description = _('Ready to confirm') + def confirm(self, confirmer=None): if not self.submitted: return False @@ -203,9 +229,14 @@ class Statement(CommonModel): if self.excursion is None: return True - for yl in self.excursion.jugendleiter.all(): - ref = _("Compensation for %(excu)s") % {'excu': self.excursion.name} - Transaction(statement=self, member=yl, amount=self.real_per_yl, confirmed=False, reference=ref).save() + # allowance + for yl in self.allowance_to.all(): + ref = _("Allowance for %(excu)s") % {'excu': self.excursion.name} + Transaction(statement=self, member=yl, amount=self.allowance_per_yl, confirmed=False, reference=ref).save() + + # subsidies (i.e. night and transportation costs) + ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name} + Transaction(statement=self, member=self.subsidy_to, amount=self.total_subsidies, confirmed=False, reference=ref).save() return True def reduce_transactions(self): @@ -300,6 +331,14 @@ class Statement(CommonModel): return cvt_to_decimal(self.total_staff / self.excursion.staff_count) + @property + def total_subsidies(self): + """ + The total amount of subsidies excluding the allowance, i.e. the transportation + and night costs per youth leader multiplied with the real number of youth leaders. + """ + return (self.transportation_per_yl + self.nights_per_yl) * self.real_staff_count + @property def total_staff(self): return self.total_per_yl * self.real_staff_count @@ -353,6 +392,7 @@ class Statement(CommonModel): 'transportation_per_yl': self.transportation_per_yl, 'total_per_yl': self.total_per_yl, 'total_staff': self.total_staff, + 'total_subsidies': self.total_subsidies, } return dict(context, **excursion_context) else: diff --git a/jdav_web/finance/templates/admin/overview_submitted_statement.html b/jdav_web/finance/templates/admin/overview_submitted_statement.html index 4fd11f8..4a33ae6 100644 --- a/jdav_web/finance/templates/admin/overview_submitted_statement.html +++ b/jdav_web/finance/templates/admin/overview_submitted_statement.html @@ -73,6 +73,25 @@ {% blocktrans %}In total this is {{ total_per_yl }}€ times {{ staff_count }}, giving {{ total_staff }}€.{% endblocktrans %}

+

+{% blocktrans %}The allowance of {{ allowance_per_yl }}€ per person should be paid to:{% endblocktrans %} +

    + {% for member in statement.allowance_to.all %} +
  • + {{ member.name }} +
  • + {% endfor %} +
+

+

+{% blocktrans %}The subsidies for night and transportation costs of {{ total_subsidies }}€ should be paid to:{% endblocktrans %} +

    +
  • + {{ statement.subsidy_to.name }} +
  • +
+

+ {% endif %}

{% trans "Total" %}

From 2e6bfc9b75d0b21d858baf1b43d344fc0a5a5b48 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 18 Jan 2025 23:01:11 +0100 Subject: [PATCH 5/7] finance/statement: make allowance_to optional If an excursion has zero admissable youth leaders, the statement and hence the excursion currently can't be saved, because the `allowance_to` field has to be non-empty, but the number of entries has to be zero. This commit hence makes the field optional. --- .../0007_alter_statement_allowance_to.py | 19 +++++++++++++++++++ jdav_web/finance/models.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 jdav_web/finance/migrations/0007_alter_statement_allowance_to.py diff --git a/jdav_web/finance/migrations/0007_alter_statement_allowance_to.py b/jdav_web/finance/migrations/0007_alter_statement_allowance_to.py new file mode 100644 index 0000000..237ced2 --- /dev/null +++ b/jdav_web/finance/migrations/0007_alter_statement_allowance_to.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.1 on 2025-01-18 22:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0033_freizeit_approved_extra_youth_leader_count'), + ('finance', '0006_statement_add_allowance_to_subsidy_to'), + ] + + operations = [ + migrations.AlterField( + model_name='statement', + name='allowance_to', + field=models.ManyToManyField(blank=True, help_text='The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.', related_name='receives_allowance_for_statements', to='members.Member', verbose_name='Pay allowance to'), + ), + ] diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index 81e62b0..cf084ca 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -61,6 +61,7 @@ class Statement(CommonModel): allowance_to = models.ManyToManyField(Member, verbose_name=_('Pay allowance to'), related_name='receives_allowance_for_statements', + blank=True, help_text=_('The youth leaders to which an allowance should be paid. The count must match the number of permitted youth leaders.')) subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'), null=True, From e2bff684718b9fb6351221321865464a43f13689 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 18 Jan 2025 23:24:32 +0100 Subject: [PATCH 6/7] members/admin: add check for valid allowance_to in finance overview --- jdav_web/members/admin.py | 4 ++ .../members/locale/de/LC_MESSAGES/django.po | 46 +++++++++++++++++-- .../admin/freizeit_finance_overview.html | 22 ++++++++- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 5311c40..56ca9c0 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -1094,6 +1094,10 @@ class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): if not memberlist.statement: messages.error(request, _("No statement found. Please add a statement and then retry.")) if "apply" in request.POST: + if not memberlist.statement.allowance_to_valid: + messages.error(request, + _("The configured recipients of the allowance don't match the regulations. Please correct this and try again.")) + return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(memberlist.pk,))) memberlist.statement.submit(get_member(request)) messages.success(request, _("Successfully submited statement. The finance department will notify you as soon as possible.")) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index b43fbfb..f52f21e 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-01-18 20:19+0100\n" +"POT-Creation-Date: 2025-01-18 23:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -390,6 +390,14 @@ msgstr "" "Keine Abrechnung angelegt. Bitte lege eine Abrechnung and und versuche es " "erneut." +#: members/admin.py +msgid "" +"The configured recipients of the allowance don't match the regulations. " +"Please correct this and try again." +msgstr "" +"Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen nicht mit " +"den Richtlinien überein. Bitte korrigiere das und versuche es erneut. " + #: members/admin.py msgid "" "Successfully submited statement. The finance department will notify you as " @@ -1121,11 +1129,31 @@ msgstr "" #: members/templates/admin/freizeit_finance_overview.html #, python-format msgid "" -"In total these are contributions of %(total_per_yl)s€ times %(staff_count)s, " -"giving %(total_staff)s€." +"The allowance of %(allowance_per_yl)s€ per person is configured to be paid " +"to:" +msgstr "" +"Die Aufwandsentschädigung von %(allowance_per_yl)s€ pro Person wird " +"ausgezahlt an:" + +#: members/templates/admin/freizeit_finance_overview.html +#, python-format +msgid "" +"The subsidies for night and transportation costs of %(total_subsidies)s€ is " +"configured to be paid to:" msgstr "" -"Insgesamt sind das Kosten von %(total_per_yl)s€ mal %(staff_count)s, " -"insgesamt also %(total_staff)s€." +"Die Zuschüsse für Übernachtungs- und Fahrtkosten von %(total_subsidies)s€ " +"werden ausgezahlt an:" + +#: members/templates/admin/freizeit_finance_overview.html +msgid "" +"Warning: The configured recipients of the allowance don't match the " +"regulations. This might be because the number of recipients is bigger then " +"the number of admissable youth leaders for this excursion." +msgstr "" +"Warnung: Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen " +"nicht mit den Richtlinien überein. Das kann daran liegen, dass die Anzahl " +"der ausgewählten Empfänger*innen die Anzahl genehmigter Jugendleiter*innen " +"übersteigt." #: members/templates/admin/freizeit_finance_overview.html msgid "LJP contributions" @@ -1824,6 +1852,14 @@ msgstr "abgelaufen" msgid "Invalid emergency contacts" msgstr "Ungültige Notfallkontakte" +#, python-format +#~ msgid "" +#~ "In total these are contributions of %(total_per_yl)s€ times " +#~ "%(staff_count)s, giving %(total_staff)s€." +#~ msgstr "" +#~ "Insgesamt sind das Kosten von %(total_per_yl)s€ mal %(staff_count)s, " +#~ "insgesamt also %(total_staff)s€." + #~ msgid "Your registration succeeded." #~ msgstr "Deine Registrierung war erfolgreich." diff --git a/jdav_web/members/templates/admin/freizeit_finance_overview.html b/jdav_web/members/templates/admin/freizeit_finance_overview.html index ffba21f..99aa909 100644 --- a/jdav_web/members/templates/admin/freizeit_finance_overview.html +++ b/jdav_web/members/templates/admin/freizeit_finance_overview.html @@ -77,8 +77,28 @@ cost plan!

-{% blocktrans %}In total these are contributions of {{ total_per_yl }}€ times {{ staff_count }}, giving {{ total_staff }}€.{% endblocktrans %} +{% blocktrans %}The allowance of {{ allowance_per_yl }}€ per person is configured to be paid to:{% endblocktrans %} +

    + {% for member in memberlist.statement.allowance_to.all %} +
  • + {{ member.name }} +
  • + {% endfor %} +
+

+

+{% blocktrans %}The subsidies for night and transportation costs of {{ total_subsidies }}€ is configured to be paid to:{% endblocktrans %} +

    +
  • + {{ memberlist.statement.subsidy_to.name }} +
  • +

+{% if not memberlist.statement.allowance_to_valid %} +

+{% blocktrans %}Warning: The configured recipients of the allowance don't match the regulations. This might be because the number of recipients is bigger then the number of admissable youth leaders for this excursion.{% endblocktrans %} +

+{% endif %}

{% trans "LJP contributions" %}

From afdbb56d8141ddca0b0026547eee9f9717c2417e Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Sat, 18 Jan 2025 23:38:18 +0100 Subject: [PATCH 7/7] finance: don't include subsidies in theoretic total Subsidies paid for night and travel costs are expected to be listed in the bills of the associated statement. Hence, they should not be counted a second time in the theoretic total. This affects LJP and SJR applications. --- jdav_web/finance/models.py | 8 +++++++- jdav_web/members/templates/members/seminar_report.tex | 2 -- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index cf084ca..87f0e28 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -366,7 +366,13 @@ class Statement(CommonModel): @property def total_theoretic(self): - return self.total_bills_theoretic + self.total_staff + """ + The theoretic total used in SJR and LJP applications. This is the sum of all + bills (ignoring whether they are paid by the association or not) plus the + total allowance. This does not include the subsidies for night and travel costs, + since they are expected to be included in the bills. + """ + return self.total_bills_theoretic + self.total_allowance def total_pretty(self): return "{}€".format(self.total) diff --git a/jdav_web/members/templates/members/seminar_report.tex b/jdav_web/members/templates/members/seminar_report.tex index d73cb67..a5fc4d2 100644 --- a/jdav_web/members/templates/members/seminar_report.tex +++ b/jdav_web/members/templates/members/seminar_report.tex @@ -146,8 +146,6 @@ \textbf{Beschreibung} & \textbf{Betrag} \\ \midrule Aufwandsentschädigung & {{ memberlist.statement.total_allowance }} € \\ - Fahrtkosten & {{ memberlist.statement.total_transportation }} € \\ - Übernachtungskosten & {{ memberlist.statement.total_nights }} € \\ {% for bill in memberlist.statement.grouped_bills %} {{ bill.short_description|esc_all }} & {{ bill.amount }} € \\ {% endfor %}