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 6c85b0f..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-01 21:48+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." @@ -157,6 +173,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" @@ -208,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" @@ -361,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 @@ -400,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/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/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 d27b74f..87f0e28 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 _ @@ -46,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, @@ -58,6 +59,16 @@ 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', + 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, + 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) @@ -113,7 +124,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]))) @@ -147,10 +160,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): @@ -158,9 +186,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 @@ -193,9 +230,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): @@ -290,6 +332,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 @@ -307,15 +357,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): @@ -323,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) @@ -350,6 +399,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 %} +

+

+

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

+

+ {% endif %}

{% trans "Total" %}

diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index dd80e3e..83f9517 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() @@ -832,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): @@ -948,6 +1001,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.).') }), @@ -1073,6 +1127,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 4d586e9..fbc20ba 100644 --- a/jdav_web/members/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po @@ -259,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 " @@ -380,6 +398,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 " @@ -797,6 +823,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" @@ -1098,11 +1139,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" @@ -1817,6 +1878,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/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())) 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 %} +

+

+

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

+{% 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" %}

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 %} 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()