From 6bc3b304413e2502112bfebcaa5e80c101d22d80 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:09:19 +0200 Subject: [PATCH 01/28] init --- tapir/shifts/forms.py | 11 +++++++++++ tapir/shifts/models.py | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/tapir/shifts/forms.py b/tapir/shifts/forms.py index 59d0c673e..b21a65851 100644 --- a/tapir/shifts/forms.py +++ b/tapir/shifts/forms.py @@ -765,6 +765,7 @@ class Meta: "weekdays", "shift_template_group", "staffing_status", + "watched_capabilities", ] weekdays = forms.MultipleChoiceField( @@ -794,6 +795,16 @@ class Meta: widget=CheckboxSelectMultiple(), disabled=False, ) + watched_capabilities = forms.MultipleChoiceField( + required=False, + choices=SHIFT_USER_CAPABILITY_CHOICES.items(), + label=_("Notify me when these capabilities become available or unavailable"), + widget=CheckboxSelectMultiple(), + disabled=False, + help_text=_( + "Get notified when someone with specific skills registers or unregisters" + ), + ) WEEKDAYS_ERROR = _( "If weekdays or %(shift_template_group)s are selected, " diff --git a/tapir/shifts/models.py b/tapir/shifts/models.py index 14e78de3f..5c06404a5 100644 --- a/tapir/shifts/models.py +++ b/tapir/shifts/models.py @@ -1307,3 +1307,12 @@ class to generate recurring ShiftWatches from. blank=False, default=get_staffingstatus_defaults, ) + + watched_capabilities = ArrayField( + models.CharField( + max_length=128, choices=get_shift_capability_choices, blank=False + ), + default=list, + blank=True, + null=False, + ) From a4ff27836c8dbeea8355f3aa3648139622491150 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:00:07 +0200 Subject: [PATCH 02/28] migration --- ...ecurringshiftwatch_watched_capabilities.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tapir/shifts/migrations/0076_recurringshiftwatch_watched_capabilities.py diff --git a/tapir/shifts/migrations/0076_recurringshiftwatch_watched_capabilities.py b/tapir/shifts/migrations/0076_recurringshiftwatch_watched_capabilities.py new file mode 100644 index 000000000..18b30616a --- /dev/null +++ b/tapir/shifts/migrations/0076_recurringshiftwatch_watched_capabilities.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.15 on 2026-06-14 18:50 + +import django.contrib.postgres.fields +import tapir.shifts.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("shifts", "0075_shiftwatch_watched_capabilities"), + ] + + operations = [ + migrations.AddField( + model_name="recurringshiftwatch", + name="watched_capabilities", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=tapir.shifts.models.get_shift_capability_choices, + max_length=128, + ), + blank=True, + default=list, + size=None, + ), + ), + ] From ac0ed643a6e8d7e1d9f734fb7f49eb7007f3f14c Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:16:59 +0200 Subject: [PATCH 03/28] add watched_capabilities to Shiftwatchcreationservice --- tapir/shifts/services/shift_watch_creation_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tapir/shifts/services/shift_watch_creation_service.py b/tapir/shifts/services/shift_watch_creation_service.py index b5f92c486..a62d5801a 100644 --- a/tapir/shifts/services/shift_watch_creation_service.py +++ b/tapir/shifts/services/shift_watch_creation_service.py @@ -135,6 +135,7 @@ def create_shift_watches_for_recurring(cls, recurring: RecurringShiftWatch): user=recurring.user, shift=shift, staffing_status=recurring.staffing_status, + watched_capabilities=recurring.watched_capabilities, last_staffing_status=ShiftWatchCreator.get_initial_staffing_status_for_shift( shift=shift ), From e9b594116bd34d4be2615a8704639fb0f571237c Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:17:13 +0200 Subject: [PATCH 04/28] add more --- tapir/shifts/forms.py | 16 +++++++++++++--- .../templates/shifts/shiftwatch_overview.html | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tapir/shifts/forms.py b/tapir/shifts/forms.py index b21a65851..a01abdddd 100644 --- a/tapir/shifts/forms.py +++ b/tapir/shifts/forms.py @@ -789,7 +789,7 @@ class Meta: label=_("ABCD Week"), ) staffing_status = forms.MultipleChoiceField( - required=True, + required=False, choices=get_staffingstatus_choices, label=_("Shift changes you would like to be informed about"), widget=CheckboxSelectMultiple(), @@ -810,10 +810,14 @@ class Meta: "If weekdays or %(shift_template_group)s are selected, " "%(shift_templates)s may not be selected, and vice versa." ) - AT_LEAST_ONE_ERROR = _( + AT_LEAST_ONE_PATTERN_ERROR = _( "At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected." ) + AT_LEAST_ONE_TARGET_ERROR = _( + "At least one of the fields staffing_status or required capabilities must be selected." + ) + def _format_field_names(self): return { "shift_template_group": self.fields["shift_template_group"].label, @@ -824,6 +828,8 @@ def clean(self): cleaned_data = super().clean() shift_templates = cleaned_data.get("shift_templates") weekdays = cleaned_data.get("weekdays") + staffing_status = cleaned_data.get("staffing_status") + watched_capabilities = cleaned_data.get("watched_capabilities") cleaned_data["weekdays"] = list(map(int, weekdays)) shift_template_group = cleaned_data.get("shift_template_group") @@ -834,6 +840,10 @@ def clean(self): if not (shift_templates or weekdays or shift_template_group): raise forms.ValidationError( - self.AT_LEAST_ONE_ERROR % self._format_field_names() + self.AT_LEAST_ONE_PATTERN_ERROR % self._format_field_names() + ) + if not (staffing_status or watched_capabilities): + raise forms.ValidationError( + self.AT_LEAST_ONE_TARGET_ERROR % self._format_field_names() ) return cleaned_data diff --git a/tapir/shifts/templates/shifts/shiftwatch_overview.html b/tapir/shifts/templates/shifts/shiftwatch_overview.html index 6cfdf5315..4acead792 100644 --- a/tapir/shifts/templates/shifts/shiftwatch_overview.html +++ b/tapir/shifts/templates/shifts/shiftwatch_overview.html @@ -62,6 +62,7 @@
{% translate "Recurring Shift Watches" %}
{% translate "Observed changes:" %} From c0ef10ab5014be1ad21d416b97cc3c72617e5248 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:11:07 +0200 Subject: [PATCH 05/28] trnaslatsddf --- .../locale/de/LC_MESSAGES/django.po | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po index ce1f84ca4..e8e318da0 100644 --- a/tapir/translations/locale/de/LC_MESSAGES/django.po +++ b/tapir/translations/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-09 08:36+0200\n" +"POT-Creation-Date: 2026-06-14 22:10+0200\n" "PO-Revision-Date: 2025-07-07 13:06+0000\n" "Last-Translator: Weblate Admin \n" "Language-Team: German \n" @@ -289,8 +289,8 @@ msgid "I agree that my membership card will be scanned at the checkout when I ma msgstr "Ich bin damit einverstanden, dass meine Mitgliedskarte beim Einkauf an der Kasse gescannt und somit mein Einkauf erfasst und gespeichert wird:" #: accounts/templates/accounts/purchase_tracking_card.html:30 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:174 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:178 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:176 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:180 msgid "Yes,No" msgstr "Ja, Nein" @@ -338,8 +338,8 @@ msgstr "Benutzername bearbeiten" #: coop/templates/coop/membershipresignation_detail.html:107 #: coop/templates/coop/shareowner_detail.html:30 #: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:22 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:98 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:119 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:100 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:121 #: core/templates/core/featureflag_list.html:30 #: shifts/templates/shifts/shift_detail.html:61 #: shifts/templates/shifts/shift_template_detail.html:29 @@ -784,7 +784,7 @@ msgstr "Ist investierendes Mitglied" #: coop/models.py:90 coop/models.py:476 #: coop/templates/coop/draftuser_detail.html:176 #: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:48 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:173 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:175 msgid "Ratenzahlung" msgstr "Ratenzahlung" @@ -813,7 +813,7 @@ msgid "Investing" msgstr "Investierend" #: coop/models.py:369 coop/templates/coop/draftuser_detail.html:171 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:109 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:111 #: coop/views/statistics.py:135 msgid "Active" msgstr "Aktiv" @@ -958,7 +958,7 @@ msgstr "Löschen bestätigen" #: coop/templates/coop/confirm_delete_incoming_payment.html:31 #: coop/templates/coop/confirm_delete_share_ownership.html:24 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:133 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:135 #: financingcampaign/templates/financingcampaign/confirm_delete.html:24 #: shifts/templates/shifts/shift_confirm_delete.html:30 msgid "Delete" @@ -1021,7 +1021,7 @@ msgstr "Liste ähnlicher Mitglieder" #: coop/templates/coop/draftuser_detail.html:69 #: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:31 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:96 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:98 #: coop/views/shareowner.py:611 #: shifts/templates/shifts/user_shifts_overview_tag.html:26 msgid "Status" @@ -2022,7 +2022,7 @@ msgstr "Dein Konto in Tapir, das Mitgliedersystem von %(coop_name)s" #: coop/templates/coop/extra_share_request.html:7 #: coop/templates/coop/extra_share_request.html:17 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:83 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:84 msgid "Buy more shares" msgstr "Weitere Anteile zeichnen" @@ -2101,7 +2101,7 @@ msgid "General Tapir Accounts" msgstr "Allgemeine Tapir-Konten" #: coop/templates/coop/incoming_payment_list.html:10 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:166 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:168 msgid "Payments" msgstr "Zahlungen" @@ -2542,23 +2542,23 @@ msgstr "Als teilgenommen markieren" msgid "Owned shares" msgstr "Anteile" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:90 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:92 msgid "List of shares owned by this member" msgstr "Liste der Anteile die dieses Mitglied gehören" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:94 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:96 msgid "Starts at" msgstr "Anfangsdatum" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:95 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:97 msgid "Ends at" msgstr "Endet am" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:111 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:113 msgid "Sold or future" msgstr "" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:136 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:138 msgid "" "\n" " Only use this to correct mistakes, i.e. if the share was\n" @@ -2572,19 +2572,19 @@ msgstr "" "Bitte nur verwenden, wenn ein tatsächlicher Fehler vorliegt, z.B. ein Anteil eingetragen wurde, der nie gekauft wurde. Wenn die Person ihren Anteil einfach an den Coop zurückverkauft hat, markiere den Anteil bitte als 'verkauft'.\n" " " -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:159 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:161 msgid "Add Shares" msgstr "Anteile hinzufügen" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:177 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:179 msgid "Willing to gift a share" msgstr "Bereit Anteile zu schenken" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:187 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:189 msgid "Send membership confirmation email" msgstr "Mitgliedsbestätigung per Mail senden" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:193 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:195 msgid "User is not a cooperative member." msgstr "Benutzer*in ist kein Genossenschaftsmitglied." @@ -2703,7 +2703,7 @@ msgstr "Hat Qualifikation" msgid "Does not have qualification" msgstr "Hat die Qualifikation nicht" -#: coop/views/shareowner.py:656 shifts/forms.py:788 +#: coop/views/shareowner.py:656 shifts/forms.py:789 msgid "ABCD Week" msgstr "ABCD-Woche" @@ -3236,36 +3236,40 @@ msgstr "" msgid "I understand that this will delete the shift exemption and create a membership pause" msgstr "" -#: shifts/forms.py:725 shifts/forms.py:793 shifts/views/views.py:377 +#: shifts/forms.py:725 shifts/forms.py:794 shifts/views/views.py:377 #: shifts/views/views.py:378 msgid "Shift changes you would like to be informed about" msgstr "Schicht-Änderungen, bei denen du informiert werden möchtest" -#: shifts/forms.py:732 +#: shifts/forms.py:732 shifts/forms.py:801 msgid "Notify me when these capabilities become available or unavailable" msgstr "" -#: shifts/forms.py:736 +#: shifts/forms.py:736 shifts/forms.py:805 msgid "Get notified when someone with specific skills registers or unregisters" msgstr "" -#: shifts/forms.py:782 shifts/templates/shifts/shift_template_detail.html:7 +#: shifts/forms.py:783 shifts/templates/shifts/shift_template_detail.html:7 #: shifts/templates/shifts/shift_template_detail.html:15 #: shifts/templates/shifts/user_shifts_overview_tag.html:31 #: shifts/templates/shifts/user_shifts_overview_tag.html:43 msgid "ABCD Shift" msgstr "ABCD-Schicht" -#: shifts/forms.py:799 +#: shifts/forms.py:810 #, python-format msgid "If weekdays or %(shift_template_group)s are selected, %(shift_templates)s may not be selected, and vice versa." msgstr "" -#: shifts/forms.py:803 +#: shifts/forms.py:814 #, python-format msgid "At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected." msgstr "" +#: shifts/forms.py:818 +msgid "At least one of the fields staffing_status or required capabilities must be selected." +msgstr "" + #: shifts/models.py:39 msgid "Teamleader" msgstr "Teamleiter*in" @@ -4269,7 +4273,7 @@ msgid "Unwatch" msgstr "Nicht mehr beobachten" #: shifts/templates/shifts/shift_detail.html:45 -#: shifts/templates/shifts/shiftwatch_overview.html:123 +#: shifts/templates/shifts/shiftwatch_overview.html:124 msgid "You will get mail-notifications when shifts you follow change — tailored to the types of updates you choose (e.g., needs help, is full, cancellations)." msgstr "Du erhältst E-Mail-Benachrichtigungen, wenn sich Schichten, denen du folgst, ändern – angepasst an die von dir ausgewählten Arten von Aktualisierungen (z. B. Hilfe benötigt, voll, Stornierungen)." @@ -4691,32 +4695,32 @@ msgstr "Du beobachtest keine Schichten" msgid "Observed changes:" msgstr "Beobachtete Änderungen" -#: shifts/templates/shifts/shiftwatch_overview.html:75 +#: shifts/templates/shifts/shiftwatch_overview.html:76 msgid "Shift Watches" msgstr "Schicht-Beobachtungen" -#: shifts/templates/shifts/shiftwatch_overview.html:81 +#: shifts/templates/shifts/shiftwatch_overview.html:82 msgid "Total" msgstr "Insgesamt" -#: shifts/templates/shifts/shiftwatch_overview.html:104 +#: shifts/templates/shifts/shiftwatch_overview.html:105 #: shifts/templates/shifts/user_shifts_overview_tag.html:64 msgid "Show more" msgstr "Mehr anzeigen" -#: shifts/templates/shifts/shiftwatch_overview.html:126 +#: shifts/templates/shifts/shiftwatch_overview.html:127 msgid "You can watch single shifts for changes by selecting a shift " msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:127 +#: shifts/templates/shifts/shiftwatch_overview.html:128 msgid "here" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:128 +#: shifts/templates/shifts/shiftwatch_overview.html:129 msgid " and clicking on the Watch-button" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:134 +#: shifts/templates/shifts/shiftwatch_overview.html:135 msgid "Delete Selected" msgstr "Lösche das Ausgewählte" From dbc18ee186364d6d44fd86adbdb1574d2beba175 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:48:44 +0200 Subject: [PATCH 06/28] tests --- .../test_CreateWatchRecurringShiftsView.py | 96 ++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py b/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py index 31b3bf300..7b0f698a4 100644 --- a/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py +++ b/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py @@ -2,7 +2,11 @@ from tapir.accounts.models import TapirUser from tapir.accounts.tests.factories.factories import TapirUserFactory -from tapir.shifts.models import RecurringShiftWatch, StaffingStatusChoices +from tapir.shifts.models import ( + RecurringShiftWatch, + ShiftUserCapability, + StaffingStatusChoices, +) from tapir.shifts.tests.factories import ShiftTemplateFactory from tapir.utils.tests_utils import TapirFactoryTestBase @@ -131,3 +135,93 @@ def test_createRecurringShiftWatch_memberOfficeAttemptsToCreateForOthers_entryCr self.assertEqual(302, response.status_code) self.assertEqual(RecurringShiftWatch.objects.count(), 1) + + def test_createRecurringShiftWatch_withWatchedCapabilities_entrySaved(self): + form_data = { + **self.default_form_data, + "weekdays": [1, 2], + "shift_template_group": ["A"], + "watched_capabilities": [ + ShiftUserCapability.CASHIER, + ShiftUserCapability.BREAD_DELIVERY, + ], + } + + response = self.client.post( + reverse(self.VIEW_NAME, args=[self.tapir_user.pk]), data=form_data + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(RecurringShiftWatch.objects.count(), 1) + created_watch = RecurringShiftWatch.objects.first() + + self.assertEqual( + set(created_watch.watched_capabilities), + {ShiftUserCapability.CASHIER, ShiftUserCapability.BREAD_DELIVERY}, + ) + + def test_createRecurringShiftWatch_noCapabilitiesSelected_entryCreated(self): + form_data = { + **self.default_form_data, + "weekdays": [1, 2], + "shift_template_group": ["A"], + "watched_capabilities": [], + } + + response = self.client.post( + reverse(self.VIEW_NAME, args=[self.tapir_user.pk]), data=form_data + ) + + self.assertEqual(response.status_code, 302) + created_watch = RecurringShiftWatch.objects.first() + self.assertEqual(created_watch.watched_capabilities, []) + + def test_createRecurringShiftWatch_invalidCapability_validationError(self): + form_data = { + **self.default_form_data, + "weekdays": [1, 2], + "shift_template_group": ["A"], + "watched_capabilities": ["invalid_capability"], + } + + response = self.client.post( + reverse(self.VIEW_NAME, args=[self.tapir_user.pk]), data=form_data + ) + + form = response.context["form"] + self.assertFalse(form.is_valid()) + self.assertIn("watched_capabilities", form.errors) + + def test_createRecurringShiftWatch_withCapabilitiesAndStaffingStatus_entrySaved( + self, + ): + form_data = { + **self.default_form_data, + "weekdays": [1, 2], + "shift_template_group": ["A"], + "staffing_status": [ + StaffingStatusChoices.UNDERSTAFFED, + StaffingStatusChoices.FULL, + ], + "watched_capabilities": [ + ShiftUserCapability.CASHIER, + ShiftUserCapability.BREAD_DELIVERY, + ], + } + + response = self.client.post( + reverse(self.VIEW_NAME, args=[self.tapir_user.pk]), data=form_data + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(RecurringShiftWatch.objects.count(), 1) + created_watch = RecurringShiftWatch.objects.first() + + self.assertEqual( + set(created_watch.staffing_status), + {StaffingStatusChoices.UNDERSTAFFED, StaffingStatusChoices.FULL}, + ) + self.assertEqual( + set(created_watch.watched_capabilities), + {ShiftUserCapability.CASHIER, ShiftUserCapability.BREAD_DELIVERY}, + ) From c1e5ee4b8f4b8fa8c284034420f3b319bdeef4e4 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:52:51 +0200 Subject: [PATCH 07/28] translation --- .../locale/de/LC_MESSAGES/django.po | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po index f376e8a04..0785a46a7 100644 --- a/tapir/translations/locale/de/LC_MESSAGES/django.po +++ b/tapir/translations/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-15 17:00+0200\n" +"POT-Creation-Date: 2026-06-16 21:48+0200\n" "PO-Revision-Date: 2025-07-07 13:06+0000\n" "Last-Translator: Weblate Admin \n" "Language-Team: German \n" @@ -2703,7 +2703,7 @@ msgstr "Hat Qualifikation" msgid "Does not have qualification" msgstr "Hat die Qualifikation nicht" -#: coop/views/shareowner.py:656 shifts/forms.py:788 +#: coop/views/shareowner.py:656 shifts/forms.py:789 msgid "ABCD Week" msgstr "ABCD-Woche" @@ -3236,36 +3236,40 @@ msgstr "" msgid "I understand that this will delete the shift exemption and create a membership pause" msgstr "" -#: shifts/forms.py:725 shifts/forms.py:793 shifts/views/views.py:377 +#: shifts/forms.py:725 shifts/forms.py:794 shifts/views/views.py:377 #: shifts/views/views.py:378 msgid "Shift changes you would like to be informed about" msgstr "Schicht-Änderungen, bei denen du informiert werden möchtest" -#: shifts/forms.py:732 +#: shifts/forms.py:732 shifts/forms.py:801 msgid "Notify me when these capabilities become available or unavailable" msgstr "" -#: shifts/forms.py:736 +#: shifts/forms.py:736 shifts/forms.py:805 msgid "Get notified when someone with specific skills registers or unregisters" msgstr "" -#: shifts/forms.py:782 shifts/templates/shifts/shift_template_detail.html:7 +#: shifts/forms.py:783 shifts/templates/shifts/shift_template_detail.html:7 #: shifts/templates/shifts/shift_template_detail.html:15 #: shifts/templates/shifts/user_shifts_overview_tag.html:31 #: shifts/templates/shifts/user_shifts_overview_tag.html:43 msgid "ABCD Shift" msgstr "ABCD-Schicht" -#: shifts/forms.py:799 +#: shifts/forms.py:810 #, python-format msgid "If weekdays or %(shift_template_group)s are selected, %(shift_templates)s may not be selected, and vice versa." msgstr "" -#: shifts/forms.py:803 +#: shifts/forms.py:814 #, python-format msgid "At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected." msgstr "" +#: shifts/forms.py:818 +msgid "At least one of the fields staffing_status or required capabilities must be selected." +msgstr "" + #: shifts/models.py:39 msgid "Teamleader" msgstr "Teamleiter*in" @@ -4269,7 +4273,7 @@ msgid "Unwatch" msgstr "Nicht mehr beobachten" #: shifts/templates/shifts/shift_detail.html:45 -#: shifts/templates/shifts/shiftwatch_overview.html:123 +#: shifts/templates/shifts/shiftwatch_overview.html:124 msgid "You will get mail-notifications when shifts you follow change — tailored to the types of updates you choose (e.g., needs help, is full, cancellations)." msgstr "Du erhältst E-Mail-Benachrichtigungen, wenn sich Schichten, denen du folgst, ändern – angepasst an die von dir ausgewählten Arten von Aktualisierungen (z. B. Hilfe benötigt, voll, Stornierungen)." @@ -4691,32 +4695,32 @@ msgstr "Du beobachtest keine Schichten" msgid "Observed changes:" msgstr "Beobachtete Änderungen" -#: shifts/templates/shifts/shiftwatch_overview.html:75 +#: shifts/templates/shifts/shiftwatch_overview.html:76 msgid "Shift Watches" msgstr "Schicht-Beobachtungen" -#: shifts/templates/shifts/shiftwatch_overview.html:81 +#: shifts/templates/shifts/shiftwatch_overview.html:82 msgid "Total" msgstr "Insgesamt" -#: shifts/templates/shifts/shiftwatch_overview.html:104 +#: shifts/templates/shifts/shiftwatch_overview.html:105 #: shifts/templates/shifts/user_shifts_overview_tag.html:64 msgid "Show more" msgstr "Mehr anzeigen" -#: shifts/templates/shifts/shiftwatch_overview.html:126 +#: shifts/templates/shifts/shiftwatch_overview.html:127 msgid "You can watch single shifts for changes by selecting a shift " msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:127 +#: shifts/templates/shifts/shiftwatch_overview.html:128 msgid "here" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:128 +#: shifts/templates/shifts/shiftwatch_overview.html:129 msgid " and clicking on the Watch-button" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:134 +#: shifts/templates/shifts/shiftwatch_overview.html:135 msgid "Delete Selected" msgstr "Lösche das Ausgewählte" From 8e0de18a5f0c18dca6d7e84cea154efe768ac81d Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:54:26 +0200 Subject: [PATCH 08/28] test_createRecurringShiftWatch_neitherStaffingStatusNorCapabilities_validationError --- .../test_CreateWatchRecurringShiftsView.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py b/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py index 7b0f698a4..e51445661 100644 --- a/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py +++ b/tapir/shifts/tests/test_CreateWatchRecurringShiftsView.py @@ -2,6 +2,7 @@ from tapir.accounts.models import TapirUser from tapir.accounts.tests.factories.factories import TapirUserFactory +from tapir.shifts.forms import RecurringShiftWatchForm from tapir.shifts.models import ( RecurringShiftWatch, ShiftUserCapability, @@ -54,7 +55,7 @@ def test_createRecurringShiftWatch_weekdayAndShifTemplateGroup_entryCreated( def test_createRecurringShiftWatch_ShiftTemplate_entryCreated(self): form_data = { **self.default_form_data, - "shift_templates": [self.template1.id, self.template2.id], # Use actual IDs + "shift_templates": [self.template1.id, self.template2.id], } response = self.client.post( @@ -225,3 +226,25 @@ def test_createRecurringShiftWatch_withCapabilitiesAndStaffingStatus_entrySaved( set(created_watch.watched_capabilities), {ShiftUserCapability.CASHIER, ShiftUserCapability.BREAD_DELIVERY}, ) + + def test_createRecurringShiftWatch_neitherStaffingStatusNorCapabilities_validationError( + self, + ): + form_data = { + "shift_templates": [], + "weekdays": [1, 2], + "shift_template_group": [], + "staffing_status": [], + "watched_capabilities": [], + } + + response = self.client.post( + reverse(self.VIEW_NAME, args=[self.tapir_user.pk]), data=form_data + ) + + form = response.context["form"] + self.assertFalse(form.is_valid()) + self.assertIn( + RecurringShiftWatchForm.AT_LEAST_ONE_TARGET_ERROR, + form.non_field_errors(), + ) From fb36117c518a26342acb520de93b91a9798a92ad Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:20:09 +0200 Subject: [PATCH 09/28] explicitly set staffing_status --- tapir/shifts/tests/test_shiftwatch_notification.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 795fb8866..4f4b97a53 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -42,13 +42,14 @@ def create_shift_watch( shift=shift ) if staffing_status is None: - staffing_status = [event.value for event in get_staffingstatus_choices()] + staffing_status = [] return ShiftWatchFactory( user=user, shift=shift, last_valid_slot_ids=slots, staffing_status=staffing_status, last_staffing_status=last_staffing_status, + watched_capabilities=watched_capabilities, ) @@ -114,6 +115,7 @@ def test_handle_initialWatchUnderstaffedShift_noInitialMailIsSent(self): last_staffing_status=ShiftWatchCreator.get_initial_staffing_status_for_shift( shift=shift_understaffed ), + staffing_status=list(get_staffingstatus_choices()), ) Command().handle() @@ -174,6 +176,7 @@ def test_handle_shiftInThePast_noNotification(self): user=self.user, shift=self.shift_ok_first, slots=self.slots, + staffing_status=list(get_staffingstatus_choices()), ) self.unregister_first_slot() From d5a83ab3edb8238e20efe41867d31222e30b9e28 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:20:55 +0200 Subject: [PATCH 10/28] assert_email_send now expects string --- tapir/shifts/tests/test_shiftwatch_notification.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 4f4b97a53..1f99a0704 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -69,9 +69,9 @@ def unregister_first_slot(self): first_shift_attendance.state = ShiftAttendance.State.LOOKING_FOR_STAND_IN first_shift_attendance.save() - def assert_email_sent(self, expected_status_choice): + def assert_email_sent(self, expected_status_choice: str): self.assertEqual(len(mail.outbox), 1) - self.assertIn(str(expected_status_choice.label), mail.outbox[0].body) + self.assertIn(str(expected_status_choice), mail.outbox[0].body) self.assertEmailOfClass_GotSentTo( ShiftWatchEmailBuilder, self.USER_EMAIL_ADDRESS, mail.outbox[0] ) @@ -88,7 +88,8 @@ def test_handle_watchedShiftIsUnderstaffed_correctNotificationIsSent(self): self.unregister_first_slot() Command().handle() - self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED) + self.assertEqual(1, len(mail.outbox)) + self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED.label) def test_handle_watchedShiftIsAlright_noNotificationIsSent(self): self.shift_watch = create_shift_watch( @@ -128,7 +129,7 @@ def test_handle_initialWatchUnderstaffedShift_noInitialMailIsSent(self): Command().handle() - self.assert_email_sent(StaffingStatusChoices.ALL_CLEAR) + self.assert_email_sent(StaffingStatusChoices.ALL_CLEAR.label) def test_handle_triggeredMultipleTimes_onlyOneMailIsSent(self): self.shift_watch = create_shift_watch( @@ -145,7 +146,7 @@ def test_handle_triggeredMultipleTimes_onlyOneMailIsSent(self): for _ in range(3): Command().handle() - self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED) + self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED.label) def test_handle_watchedShiftIsCurrentlyRunning_correctNotificationIsSent(self): self.shift_ok_first.start_time = timezone.now() - datetime.timedelta(hours=4) @@ -162,7 +163,7 @@ def test_handle_watchedShiftIsCurrentlyRunning_correctNotificationIsSent(self): self.unregister_first_slot() Command().handle() - self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED) + self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED.label) def test_handle_shiftInThePast_noNotification(self): From 17cf67c1c99c99114f2c09d30b094becf38d0b41 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:21:22 +0200 Subject: [PATCH 11/28] watched_capabilities --- tapir/shifts/tests/test_shiftwatch_notification.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 1f99a0704..bc37276cf 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -35,7 +35,12 @@ def create_shift_with_attendance(num_attendances): def create_shift_watch( - user, shift, slots, last_staffing_status=None, staffing_status=None + user, + shift, + slots, + last_staffing_status=None, + staffing_status=None, + watched_capabilities=None, ): if last_staffing_status is None: last_staffing_status = ShiftWatchCreator.get_initial_staffing_status_for_shift( @@ -43,6 +48,8 @@ def create_shift_watch( ) if staffing_status is None: staffing_status = [] + if watched_capabilities is None: + watched_capabilities = [] return ShiftWatchFactory( user=user, shift=shift, From 68fefe5c426c657d9e5c84bebb82f07ac9bb8119 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:23:46 +0200 Subject: [PATCH 12/28] use slot-objects in list not pks --- tapir/shifts/tests/test_shiftwatch_notification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index bc37276cf..577822b67 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -30,7 +30,7 @@ def create_shift_with_attendance(num_attendances): slot = ShiftSlot.objects.create(shift=shift, name="cheese-making") user = TapirUserFactory.create() ShiftAttendance.objects.create(user=user, slot=slot) - slots.append(slot.pk) + slots.append(slot) return shift, slots @@ -53,7 +53,7 @@ def create_shift_watch( return ShiftWatchFactory( user=user, shift=shift, - last_valid_slot_ids=slots, + last_valid_slot_ids=[slot.pk for slot in slots], staffing_status=staffing_status, last_staffing_status=last_staffing_status, watched_capabilities=watched_capabilities, From 9b2c2bc86a9501dd186e67ea8167d65c98c7a8d5 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:02:31 +0200 Subject: [PATCH 13/28] test_handle_noStaffingStatusSelected_noMailSent --- .../tests/test_shiftwatch_notification.py | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 577822b67..97cfc315b 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -10,6 +10,7 @@ RecurringShiftWatch, ShiftAttendance, ShiftSlot, + ShiftUserCapability, StaffingStatusChoices, get_staffingstatus_choices, ) @@ -37,7 +38,7 @@ def create_shift_with_attendance(num_attendances): def create_shift_watch( user, shift, - slots, + last_valid_slot_ids, last_staffing_status=None, staffing_status=None, watched_capabilities=None, @@ -53,7 +54,7 @@ def create_shift_watch( return ShiftWatchFactory( user=user, shift=shift, - last_valid_slot_ids=[slot.pk for slot in slots], + last_valid_slot_ids=[slot.pk for slot in last_valid_slot_ids], staffing_status=staffing_status, last_staffing_status=last_staffing_status, watched_capabilities=watched_capabilities, @@ -70,9 +71,10 @@ def setUp(self): self.NUM_REQUIRED_ATTENDANCE ) - def unregister_first_slot(self): - first_slot = self.slots[0] - first_shift_attendance = ShiftAttendance.objects.filter(slot=first_slot).first() + def unregister_slot(self, slot: ShiftSlot | None = None): + if slot is None: + slot = self.slots[0] + first_shift_attendance = ShiftAttendance.objects.filter(slot=slot).first() first_shift_attendance.state = ShiftAttendance.State.LOOKING_FOR_STAND_IN first_shift_attendance.save() @@ -87,13 +89,13 @@ def test_handle_watchedShiftIsUnderstaffed_correctNotificationIsSent(self): self.shift_watch = create_shift_watch( user=self.user, shift=self.shift_ok_first, - slots=self.slots, + last_valid_slot_ids=self.slots, staffing_status=[StaffingStatusChoices.UNDERSTAFFED], ) Command().handle() self.assertEqual(0, len(mail.outbox)) - self.unregister_first_slot() + self.unregister_slot() Command().handle() self.assertEqual(1, len(mail.outbox)) self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED.label) @@ -102,7 +104,7 @@ def test_handle_watchedShiftIsAlright_noNotificationIsSent(self): self.shift_watch = create_shift_watch( user=self.user, shift=self.shift_ok_first, - slots=self.slots, + last_valid_slot_ids=self.slots, staffing_status=list(get_staffingstatus_choices()), ) Command().handle() @@ -119,7 +121,7 @@ def test_handle_initialWatchUnderstaffedShift_noInitialMailIsSent(self): create_shift_watch( user=user, shift=shift_understaffed, - slots=slots, + last_valid_slot_ids=slots, last_staffing_status=ShiftWatchCreator.get_initial_staffing_status_for_shift( shift=shift_understaffed ), @@ -142,11 +144,11 @@ def test_handle_triggeredMultipleTimes_onlyOneMailIsSent(self): self.shift_watch = create_shift_watch( user=self.user, shift=self.shift_ok_first, - slots=self.slots, + last_valid_slot_ids=self.slots, staffing_status=[StaffingStatusChoices.UNDERSTAFFED], ) - self.unregister_first_slot() + self.unregister_slot() self.assertEqual(len(mail.outbox), 0) @@ -163,11 +165,11 @@ def test_handle_watchedShiftIsCurrentlyRunning_correctNotificationIsSent(self): self.shift_watch = create_shift_watch( user=self.user, shift=self.shift_ok_first, - slots=self.slots, + last_valid_slot_ids=self.slots, staffing_status=[StaffingStatusChoices.UNDERSTAFFED], ) - self.unregister_first_slot() + self.unregister_slot() Command().handle() self.assert_email_sent(StaffingStatusChoices.UNDERSTAFFED.label) @@ -183,11 +185,11 @@ def test_handle_shiftInThePast_noNotification(self): self.shift_watch = create_shift_watch( user=self.user, shift=self.shift_ok_first, - slots=self.slots, + last_valid_slot_ids=self.slots, staffing_status=list(get_staffingstatus_choices()), ) - self.unregister_first_slot() + self.unregister_slot() Command().handle() @@ -207,3 +209,28 @@ def test_handle_recurring_noInitialMailIsSent(self): Command().handle() self.assertEqual(len(mail.outbox), 0) + + def test_handle_noStaffingStatusSelected_noMailSent(self): + # Only for watched capabilities + # slot = ShiftSlot.objects.filter(shift=self.shift_ok_first).second() + # slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] + + self.shift_watch = create_shift_watch( + user=self.user, + shift=self.shift_ok_first, + last_valid_slot_ids=self.slots, + staffing_status=[], + watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], + ) + print(self.shift_watch) + print( + f"required capabilities: {[slot.required_capabilities for slot in self.slots]}" + ) + + Command().handle() + self.assertEqual(0, len(mail.outbox)) + + self.unregister_slot() + Command().handle() + self.assertEqual(0, len(mail.outbox)) + # self.assert_email_sent(ShiftUserCapability.SHIFT_COORDINATOR) From 53fa85acfd74041dadd2ba11a5f3118f36d73092 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:02:50 +0200 Subject: [PATCH 14/28] fix that capabilities are shown in str --- tapir/shifts/models.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tapir/shifts/models.py b/tapir/shifts/models.py index 5c06404a5..cb6270953 100644 --- a/tapir/shifts/models.py +++ b/tapir/shifts/models.py @@ -1262,12 +1262,17 @@ class ShiftWatch(models.Model): def __str__(self): shift_name = self.shift.get_display_name() shift_url = self.shift.get_absolute_url() + + staffing_statuses = ", ".join(status for status in self.staffing_status) + watched_caps = ", ".join(cap for cap in self.watched_capabilities) + return format_html( - '{} is watching {} for changes of {}', + '{} is watching {} for changes of {} (capabilities: {})', self.user.username, shift_url, shift_name, - ", ".join(status for status in self.staffing_status), + staffing_statuses, + watched_caps, ) class Meta: From eaa4f2c15cb6b364df8f06b70c8dedd961d34691 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:24:59 +0200 Subject: [PATCH 15/28] fix: only consider staffing_status if user wants to and only consider watched_capability if user wants to --- .../commands/send_shift_watch_mail.py | 61 ++++++++------- .../tests/test_shiftwatch_notification.py | 75 ++++++++++++++++++- 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index 7802c0adc..74a9a5cff 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -31,36 +31,43 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch) ) valid_attendances_count = len(this_valid_slot_ids) - required_attendances_count = shift_watch_data.shift.num_required_attendances - number_of_available_slots = shift_watch_data.shift.slots.count() - # Determine staffing status - current_status = ShiftWatchCreator.get_staffing_status_if_changed( - number_of_available_slots=number_of_available_slots, - valid_attendances=valid_attendances_count, - required_attendances=required_attendances_count, - last_status=shift_watch_data.last_staffing_status, - ) - if current_status: - notification_reasons.append(current_status.label) - shift_watch_data.last_staffing_status = current_status + if len(shift_watch_data.staffing_status) > 0: + # Determine staffing status + current_status = ShiftWatchCreator.get_staffing_status_if_changed( + number_of_available_slots=shift_watch_data.shift.slots.count(), + valid_attendances=valid_attendances_count, + required_attendances=shift_watch_data.shift.num_required_attendances, + last_status=shift_watch_data.last_staffing_status, + ) + if current_status: + notification_reasons.append(current_status.label) + shift_watch_data.last_staffing_status = current_status - # Check watched capabilities - capability_notifications = ShiftWatchCreator.get_capability_status_changes( - this_valid_slot_ids=this_valid_slot_ids, - last_valid_slot_ids=shift_watch_data.last_valid_slot_ids, - watched_capabilities=shift_watch_data.watched_capabilities, - ) - notification_reasons.extend(capability_notifications) + # General attendance change notifications + if not notification_reasons: + if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids): + notification_reasons.append( + StaffingStatusChoices.ATTENDANCE_PLUS.label + ) + elif valid_attendances_count < len( + shift_watch_data.last_valid_slot_ids + ): + notification_reasons.append( + StaffingStatusChoices.ATTENDANCE_MINUS.label + ) + print(shift_watch_data.watched_capabilities) + print(f"required capabilities really: {shift_watch_data.shift.slots}") - # General attendance change notifications - if not notification_reasons: - if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids): - notification_reasons.append(StaffingStatusChoices.ATTENDANCE_PLUS.label) - elif valid_attendances_count < len(shift_watch_data.last_valid_slot_ids): - notification_reasons.append( - StaffingStatusChoices.ATTENDANCE_MINUS.label - ) + if len(shift_watch_data.watched_capabilities) > 0: + # Check watched capabilities + capability_notifications = ShiftWatchCreator.get_capability_status_changes( + this_valid_slot_ids=this_valid_slot_ids, + last_valid_slot_ids=shift_watch_data.last_valid_slot_ids, + watched_capabilities=shift_watch_data.watched_capabilities, + ) + if capability_notifications: + notification_reasons.extend(capability_notifications) for reason in notification_reasons: self.send_shift_watch_mail(shift_watch=shift_watch_data, reason=reason) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 97cfc315b..cdb2ebc2d 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -230,7 +230,78 @@ def test_handle_noStaffingStatusSelected_noMailSent(self): Command().handle() self.assertEqual(0, len(mail.outbox)) - self.unregister_slot() + slot_to_unregister = self.slots[0] + + # assert that slot to unregister has no required capability, so it should not trigger notification + self.assertNotEqual( + slot_to_unregister.required_capabilities, + ShiftUserCapability.SHIFT_COORDINATOR, + ) + self.unregister_slot(slot=slot_to_unregister) + Command().handle() + + self.assertEqual(0, len(mail.outbox)) + + def test_handle_watchedCapability_MailSent(self): + # Only for watched capabilities + # slot = ShiftSlot.objects.filter(shift=self.shift_ok_first).second() + # slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] + slot_to_unregister = self.slots[0] + slot_to_unregister.required_capabilities = ShiftUserCapability.SHIFT_COORDINATOR + self.shift_watch = create_shift_watch( + user=self.user, + shift=self.shift_ok_first, + last_valid_slot_ids=self.slots, + staffing_status=[], + watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], + ) + print(self.shift_watch) + print( + f"required capabilities: {[slot.required_capabilities for slot in self.slots]}" + ) + + Command().handle() + self.assertEqual(0, len(mail.outbox)) + + # assert that slot to unregister has no required capability, so it should not trigger notification + self.assertEqual( + slot_to_unregister.required_capabilities, + ShiftUserCapability.SHIFT_COORDINATOR, + ) + self.unregister_slot(slot=slot_to_unregister) + Command().handle() + self.assertEqual(1, len(mail.outbox)) + + def test_handle_watchDifferentCapability_noMailSent(self): + # Only for watched capabilities + # slot = ShiftSlot.objects.filter(shift=self.shift_ok_first).second() + # slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] + slot_to_unregister = self.slots[0] + slot_to_unregister.required_capabilities = ShiftUserCapability.CASHIER + self.shift_watch = create_shift_watch( + user=self.user, + shift=self.shift_ok_first, + last_valid_slot_ids=self.slots, + staffing_status=[], + watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], + ) + print(self.shift_watch) + print( + f"required capabilities: {[slot.required_capabilities for slot in self.slots]}" + ) + + Command().handle() + self.assertEqual(0, len(mail.outbox)) + + # assert that slot to unregister has no required capability, so it should not trigger notification + self.assertEqual( + slot_to_unregister.required_capabilities, + ShiftUserCapability.CASHIER, + ) + self.assertNotEqual( + slot_to_unregister.required_capabilities, + ShiftUserCapability.SHIFT_COORDINATOR, + ) + self.unregister_slot(slot=slot_to_unregister) Command().handle() self.assertEqual(0, len(mail.outbox)) - # self.assert_email_sent(ShiftUserCapability.SHIFT_COORDINATOR) From 002084978cb7a7e969b9927b9071d5e9d6ede92b Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:43:50 +0200 Subject: [PATCH 16/28] remove print() --- tapir/shifts/management/commands/send_shift_watch_mail.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index 74a9a5cff..803794682 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -56,8 +56,6 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch) notification_reasons.append( StaffingStatusChoices.ATTENDANCE_MINUS.label ) - print(shift_watch_data.watched_capabilities) - print(f"required capabilities really: {shift_watch_data.shift.slots}") if len(shift_watch_data.watched_capabilities) > 0: # Check watched capabilities From 1c62efedb8d2bf54007579f16e1dca88cb4084f7 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:44:39 +0200 Subject: [PATCH 17/28] remove print() --- .../tests/test_shiftwatch_notification.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index cdb2ebc2d..1fd9e1f8c 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -212,8 +212,6 @@ def test_handle_recurring_noInitialMailIsSent(self): def test_handle_noStaffingStatusSelected_noMailSent(self): # Only for watched capabilities - # slot = ShiftSlot.objects.filter(shift=self.shift_ok_first).second() - # slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] self.shift_watch = create_shift_watch( user=self.user, @@ -222,10 +220,6 @@ def test_handle_noStaffingStatusSelected_noMailSent(self): staffing_status=[], watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], ) - print(self.shift_watch) - print( - f"required capabilities: {[slot.required_capabilities for slot in self.slots]}" - ) Command().handle() self.assertEqual(0, len(mail.outbox)) @@ -243,9 +237,6 @@ def test_handle_noStaffingStatusSelected_noMailSent(self): self.assertEqual(0, len(mail.outbox)) def test_handle_watchedCapability_MailSent(self): - # Only for watched capabilities - # slot = ShiftSlot.objects.filter(shift=self.shift_ok_first).second() - # slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] slot_to_unregister = self.slots[0] slot_to_unregister.required_capabilities = ShiftUserCapability.SHIFT_COORDINATOR self.shift_watch = create_shift_watch( @@ -255,10 +246,6 @@ def test_handle_watchedCapability_MailSent(self): staffing_status=[], watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], ) - print(self.shift_watch) - print( - f"required capabilities: {[slot.required_capabilities for slot in self.slots]}" - ) Command().handle() self.assertEqual(0, len(mail.outbox)) @@ -285,10 +272,6 @@ def test_handle_watchDifferentCapability_noMailSent(self): staffing_status=[], watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], ) - print(self.shift_watch) - print( - f"required capabilities: {[slot.required_capabilities for slot in self.slots]}" - ) Command().handle() self.assertEqual(0, len(mail.outbox)) From 06065140be610b27c785ee2d282c5e4f6493f0e9 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:44:59 +0200 Subject: [PATCH 18/28] ShiftUserCapabilities should array --- .../tests/test_shiftwatch_notification.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 1fd9e1f8c..6c2a0fc21 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -229,7 +229,7 @@ def test_handle_noStaffingStatusSelected_noMailSent(self): # assert that slot to unregister has no required capability, so it should not trigger notification self.assertNotEqual( slot_to_unregister.required_capabilities, - ShiftUserCapability.SHIFT_COORDINATOR, + [ShiftUserCapability.SHIFT_COORDINATOR], ) self.unregister_slot(slot=slot_to_unregister) Command().handle() @@ -238,7 +238,10 @@ def test_handle_noStaffingStatusSelected_noMailSent(self): def test_handle_watchedCapability_MailSent(self): slot_to_unregister = self.slots[0] - slot_to_unregister.required_capabilities = ShiftUserCapability.SHIFT_COORDINATOR + slot_to_unregister.required_capabilities = [ + ShiftUserCapability.SHIFT_COORDINATOR + ] + slot_to_unregister.save() self.shift_watch = create_shift_watch( user=self.user, shift=self.shift_ok_first, @@ -250,21 +253,20 @@ def test_handle_watchedCapability_MailSent(self): Command().handle() self.assertEqual(0, len(mail.outbox)) - # assert that slot to unregister has no required capability, so it should not trigger notification self.assertEqual( slot_to_unregister.required_capabilities, - ShiftUserCapability.SHIFT_COORDINATOR, + [ShiftUserCapability.SHIFT_COORDINATOR], ) self.unregister_slot(slot=slot_to_unregister) Command().handle() self.assertEqual(1, len(mail.outbox)) def test_handle_watchDifferentCapability_noMailSent(self): - # Only for watched capabilities - # slot = ShiftSlot.objects.filter(shift=self.shift_ok_first).second() - # slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] + # watch for Shift-Coordinator, but shift has Cashier-capability slot_to_unregister = self.slots[0] - slot_to_unregister.required_capabilities = ShiftUserCapability.CASHIER + slot_to_unregister.required_capabilities = [ShiftUserCapability.CASHIER] + slot_to_unregister.save() + self.shift_watch = create_shift_watch( user=self.user, shift=self.shift_ok_first, @@ -276,14 +278,13 @@ def test_handle_watchDifferentCapability_noMailSent(self): Command().handle() self.assertEqual(0, len(mail.outbox)) - # assert that slot to unregister has no required capability, so it should not trigger notification self.assertEqual( slot_to_unregister.required_capabilities, - ShiftUserCapability.CASHIER, + [ShiftUserCapability.CASHIER], ) self.assertNotEqual( slot_to_unregister.required_capabilities, - ShiftUserCapability.SHIFT_COORDINATOR, + [ShiftUserCapability.SHIFT_COORDINATOR], ) self.unregister_slot(slot=slot_to_unregister) Command().handle() From 9bd4b3d5ba8368f647d2a623ec146047cf07be9b Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:06:12 +0200 Subject: [PATCH 19/28] add another capability to be sure --- tapir/shifts/tests/test_shiftwatch_notification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 6c2a0fc21..abd388134 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -265,6 +265,7 @@ def test_handle_watchDifferentCapability_noMailSent(self): # watch for Shift-Coordinator, but shift has Cashier-capability slot_to_unregister = self.slots[0] slot_to_unregister.required_capabilities = [ShiftUserCapability.CASHIER] + self.slots[1].required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] slot_to_unregister.save() self.shift_watch = create_shift_watch( From 47981885e7672e4605a842dbfc7805eeb30dda57 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:06:43 +0200 Subject: [PATCH 20/28] test_handle_registerAnotherUserToSameCapability_MailSent --- .../tests/test_shiftwatch_notification.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index abd388134..df7a17011 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -290,3 +290,36 @@ def test_handle_watchDifferentCapability_noMailSent(self): self.unregister_slot(slot=slot_to_unregister) Command().handle() self.assertEqual(0, len(mail.outbox)) + + def test_handle_registerAnotherUserToSameCapability_MailSent(self): + slot_to_register = ShiftSlot.objects.create( + shift=self.shift_ok_first, name="cheese-making" + ) + self.slots.append(slot_to_register) + for slot in self.slots: + slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] + slot.save() + + self.shift_watch = create_shift_watch( + user=self.user, + shift=self.shift_ok_first, + last_valid_slot_ids=self.slots, + staffing_status=[], + watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], + ) + # TODO assert first and second shift have attendance and third shift has no attendance + Command().handle() + self.assertEqual(0, len(mail.outbox)) + + # register user to third slot + user = TapirUserFactory.create() + ShiftAttendance.objects.create(user=user, slot=slot_to_register) + slot_to_register.save() + + self.assertEqual( + slot_to_register.required_capabilities, + [ShiftUserCapability.SHIFT_COORDINATOR], + ) + + Command().handle() + self.assertEqual(1, len(mail.outbox)) From 6b1ca1d007c82cd5d2c7fbe8c77cd6a5cc614269 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:11:49 +0200 Subject: [PATCH 21/28] linting --- tapir/shifts/templates/shifts/shift_detail.html | 4 +++- tapir/shifts/tests/test_get_past_shifts_data.py | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tapir/shifts/templates/shifts/shift_detail.html b/tapir/shifts/templates/shifts/shift_detail.html index 40a77433d..bb674f208 100644 --- a/tapir/shifts/templates/shifts/shift_detail.html +++ b/tapir/shifts/templates/shifts/shift_detail.html @@ -88,7 +88,9 @@
    - {% if shift.description %}
  • {{ shift.description }}
  • {% endif %} + {% if shift.description %} +
  • {{ shift.description }}
  • + {% endif %} {% if shift.cancelled %}
  • {% translate 'This shift has been cancelled: ' %} diff --git a/tapir/shifts/tests/test_get_past_shifts_data.py b/tapir/shifts/tests/test_get_past_shifts_data.py index 5d7a1cf41..5ece8db03 100644 --- a/tapir/shifts/tests/test_get_past_shifts_data.py +++ b/tapir/shifts/tests/test_get_past_shifts_data.py @@ -4,9 +4,9 @@ from tapir.accounts.tests.factories.factories import TapirUserFactory from tapir.shifts.models import ( - ShiftTemplate, - ShiftAttendance, Shift, + ShiftAttendance, + ShiftTemplate, ) from tapir.shifts.tests.factories import ShiftFactory, ShiftTemplateFactory from tapir.shifts.views import ShiftDetailView @@ -74,8 +74,8 @@ def test_getPastShiftsData_multipleAttendandances_correctValues(self): def test_getPastShiftsData_changedShiftTemplateDuration_correctSum(self): shift_template: ShiftTemplate = ShiftTemplateFactory.create( - start_time=datetime.time(hour=10, tzinfo=datetime.timezone.utc), - end_time=datetime.time(hour=12, tzinfo=datetime.timezone.utc), + start_time=datetime.time(hour=10, tzinfo=datetime.UTC), + end_time=datetime.time(hour=12, tzinfo=datetime.UTC), ) shift_2_hours = shift_template.create_shift_if_necessary( timezone.now() - datetime.timedelta(days=7) @@ -86,7 +86,7 @@ def test_getPastShiftsData_changedShiftTemplateDuration_correctSum(self): state=ShiftAttendance.State.DONE, ) - shift_template.end_time = datetime.time(hour=15, tzinfo=datetime.timezone.utc) + shift_template.end_time = datetime.time(hour=15, tzinfo=datetime.UTC) shift_template.save() shift_5_hours = shift_template.create_shift_if_necessary( From 80205ed90a1dce5cd8471a0db2533a68ec67663c Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:34:16 +0200 Subject: [PATCH 22/28] translation --- .../locale/de/LC_MESSAGES/django.po | 100 +++++++++--------- 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po index 0560d079a..82c9d566a 100644 --- a/tapir/translations/locale/de/LC_MESSAGES/django.po +++ b/tapir/translations/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-30 18:03+0200\n" +"POT-Creation-Date: 2026-07-09 20:33+0200\n" "PO-Revision-Date: 2025-07-07 13:06+0000\n" "Last-Translator: Weblate Admin \n" "Language-Team: German \n" @@ -1112,7 +1112,7 @@ msgstr "Bewerber*in erzeugen" #: coop/templates/coop/draftuser_register_form.html:29 #: shifts/templates/shifts/register_user_to_shift_slot.html:27 #: shifts/templates/shifts/register_user_to_shift_slot_template.html:26 -#: shifts/templates/shifts/shift_detail.html:247 +#: shifts/templates/shifts/shift_detail.html:249 #: shifts/templates/shifts/shift_template_detail.html:88 msgid "Register" msgstr "Anmelden" @@ -2524,7 +2524,7 @@ msgstr "" #: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:56 #: shifts/models.py:933 shifts/templates/shifts/shift_day_printable.html:214 #: shifts/templates/shifts/shift_day_printable.html:276 -#: shifts/templates/shifts/shift_detail.html:302 +#: shifts/templates/shifts/shift_detail.html:304 #: shifts/templates/shifts/shift_detail_printable.html:51 msgid "Attended" msgstr "Teilgenommen" @@ -2703,7 +2703,7 @@ msgstr "Hat Qualifikation" msgid "Does not have qualification" msgstr "Hat die Qualifikation nicht" -#: coop/views/shareowner.py:656 shifts/forms.py:788 +#: coop/views/shareowner.py:656 shifts/forms.py:789 msgid "ABCD Week" msgstr "ABCD-Woche" @@ -2913,7 +2913,7 @@ msgstr "Ziel" #: financingcampaign/templates/financingcampaign/general.html:29 #: financingcampaign/templates/financingcampaign/general.html:72 #: financingcampaign/templates/financingcampaign/general.html:114 -#: shifts/templates/shifts/shift_detail.html:113 +#: shifts/templates/shifts/shift_detail.html:115 msgid "Actions" msgstr "Aktionen" @@ -3236,36 +3236,40 @@ msgstr "" msgid "I understand that this will delete the shift exemption and create a membership pause" msgstr "" -#: shifts/forms.py:725 shifts/forms.py:793 shifts/views/views.py:419 +#: shifts/forms.py:725 shifts/forms.py:794 shifts/views/views.py:419 #: shifts/views/views.py:420 msgid "Shift changes you would like to be informed about" msgstr "Schicht-Änderungen, bei denen du informiert werden möchtest" -#: shifts/forms.py:732 +#: shifts/forms.py:732 shifts/forms.py:801 msgid "Notify me when these capabilities become available or unavailable" msgstr "" -#: shifts/forms.py:736 +#: shifts/forms.py:736 shifts/forms.py:805 msgid "Get notified when someone with specific skills registers or unregisters" msgstr "" -#: shifts/forms.py:782 shifts/templates/shifts/shift_template_detail.html:7 +#: shifts/forms.py:783 shifts/templates/shifts/shift_template_detail.html:7 #: shifts/templates/shifts/shift_template_detail.html:15 #: shifts/templates/shifts/user_shifts_overview_tag.html:31 #: shifts/templates/shifts/user_shifts_overview_tag.html:43 msgid "ABCD Shift" msgstr "ABCD-Schicht" -#: shifts/forms.py:799 +#: shifts/forms.py:810 #, python-format msgid "If weekdays or %(shift_template_group)s are selected, %(shift_templates)s may not be selected, and vice versa." msgstr "" -#: shifts/forms.py:803 +#: shifts/forms.py:814 #, python-format msgid "At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected." msgstr "" +#: shifts/forms.py:818 +msgid "At least one of the fields staffing_status or required capabilities must be selected." +msgstr "" + #: shifts/models.py:39 msgid "Teamleader" msgstr "Teamleiter*in" @@ -3344,7 +3348,7 @@ msgid "If enabled, members who register for that shift can choose themselves the msgstr "" #: shifts/models.py:408 shifts/models.py:824 -#: shifts/templates/shifts/shift_detail.html:116 +#: shifts/templates/shifts/shift_detail.html:118 #: shifts/templates/shifts/shift_template_detail.html:46 msgid "Chosen time" msgstr "Ausgewählte Uhrzeit" @@ -3369,7 +3373,7 @@ msgstr "" msgid "This shift lets you choose at what time you come during the day of the shift. In order to help organising the attendance, please specify when you expect to come." msgstr "Diese Schicht ermöglicht dir auszusuchen, wann du kommen magst. Um die Planung zu erleichtern, gib bitte deine erwartete Ankunftszeit an." -#: shifts/models.py:934 shifts/templates/shifts/shift_detail.html:312 +#: shifts/models.py:934 shifts/templates/shifts/shift_detail.html:314 #: shifts/templates/shifts/shift_detail_printable.html:52 msgid "Missed" msgstr "Nicht erschienen" @@ -3377,17 +3381,17 @@ msgstr "Nicht erschienen" #: shifts/models.py:935 shifts/templates/shifts/shift_day_printable.html:216 #: shifts/templates/shifts/shift_day_printable.html:281 #: shifts/templates/shifts/shift_day_printable.html:283 -#: shifts/templates/shifts/shift_detail.html:342 +#: shifts/templates/shifts/shift_detail.html:344 #: shifts/templates/shifts/shift_detail_printable.html:53 msgid "Excused" msgstr "Entschuldigt" -#: shifts/models.py:936 shifts/templates/shifts/shift_detail.html:350 +#: shifts/models.py:936 shifts/templates/shifts/shift_detail.html:352 msgid "Cancelled" msgstr "Abgesagt" #: shifts/models.py:937 shifts/templates/shifts/shift_day_printable.html:264 -#: shifts/templates/shifts/shift_detail.html:334 +#: shifts/templates/shifts/shift_detail.html:336 #: shifts/templates/shifts/shift_detail_printable.html:94 #: shifts/templates/shifts/shift_filters.html:83 msgid "Looking for a stand-in" @@ -4254,7 +4258,7 @@ msgid "Flexible time not specified" msgstr "Flexible Arbeitszeit nicht angegeben" #: shifts/templates/shifts/shift_day_printable.html:259 -#: shifts/templates/shifts/shift_detail.html:143 +#: shifts/templates/shifts/shift_detail.html:145 #: shifts/templates/shifts/shift_detail_printable.html:90 #: shifts/templates/shifts/shift_template_detail.html:68 msgid "Shift partner: " @@ -4269,7 +4273,7 @@ msgid "Unwatch" msgstr "Nicht mehr beobachten" #: shifts/templates/shifts/shift_detail.html:49 -#: shifts/templates/shifts/shiftwatch_overview.html:123 +#: shifts/templates/shifts/shiftwatch_overview.html:124 msgid "You will get mail-notifications when shifts you follow change — tailored to the types of updates you choose (e.g., needs help, is full, cancellations)." msgstr "Du erhältst E-Mail-Benachrichtigungen, wenn sich Schichten, denen du folgst, ändern – angepasst an die von dir ausgewählten Arten von Aktualisierungen (z. B. Hilfe benötigt, voll, Stornierungen)." @@ -4298,72 +4302,72 @@ msgstr "Ganze Schicht löschen" msgid "Generated from" msgstr "Erzeugt von" -#: shifts/templates/shifts/shift_detail.html:94 +#: shifts/templates/shifts/shift_detail.html:96 msgid "This shift has been cancelled: " msgstr "Diese Schicht wurde abgesagt: " -#: shifts/templates/shifts/shift_detail.html:99 +#: shifts/templates/shifts/shift_detail.html:101 msgid "This shift has been deleted." msgstr "Diese Schicht wurde gelöscht." -#: shifts/templates/shifts/shift_detail.html:106 +#: shifts/templates/shifts/shift_detail.html:108 msgid "List of slots for this shift" msgstr "Liste der Slots für diese Schicht" -#: shifts/templates/shifts/shift_detail.html:109 +#: shifts/templates/shifts/shift_detail.html:111 #: shifts/templates/shifts/shift_detail_printable.html:49 msgid "Slot" msgstr "Platz" -#: shifts/templates/shifts/shift_detail.html:109 +#: shifts/templates/shifts/shift_detail.html:111 msgid "Number" msgstr "Nummer" -#: shifts/templates/shifts/shift_detail.html:110 +#: shifts/templates/shifts/shift_detail.html:112 #: shifts/templates/shifts/shift_template_detail.html:42 msgid "Details" msgstr "Details" -#: shifts/templates/shifts/shift_detail.html:111 +#: shifts/templates/shifts/shift_detail.html:113 #: shifts/templates/shifts/shift_template_detail.html:44 msgid "Registered user" msgstr "Angemeldete*r Nutzer*in" -#: shifts/templates/shifts/shift_detail.html:112 +#: shifts/templates/shifts/shift_detail.html:114 msgid "Attendance" msgstr "Anwesenheit" -#: shifts/templates/shifts/shift_detail.html:114 +#: shifts/templates/shifts/shift_detail.html:116 msgid "Do you meet the requirements?" msgstr "Erfüllst du die Voraussetzungen?" -#: shifts/templates/shifts/shift_detail.html:119 +#: shifts/templates/shifts/shift_detail.html:121 #: shifts/templates/shifts/shift_template_detail.html:49 msgid "Member-Office actions" msgstr "Mitgliederbüro Aktionen" -#: shifts/templates/shifts/shift_detail.html:120 +#: shifts/templates/shifts/shift_detail.html:122 msgid "Previous attendances" msgstr "Vorherige Anwesenheiten" -#: shifts/templates/shifts/shift_detail.html:151 +#: shifts/templates/shifts/shift_detail.html:153 msgid "since" msgstr "seit" -#: shifts/templates/shifts/shift_detail.html:164 +#: shifts/templates/shifts/shift_detail.html:166 msgid "Vacant" msgstr "Frei" -#: shifts/templates/shifts/shift_detail.html:181 +#: shifts/templates/shifts/shift_detail.html:183 msgid "Cancels the search for a stand-in. Use this if you want to attend the shift." msgstr "Beendet die Suche nach Vertretung. Benutze dies, wenn du die Schicht wahrnehmen möchtest." -#: shifts/templates/shifts/shift_detail.html:183 -#: shifts/templates/shifts/shift_detail.html:323 +#: shifts/templates/shifts/shift_detail.html:185 +#: shifts/templates/shifts/shift_detail.html:325 msgid "Cancel looking for a stand-in" msgstr "Beende die Suche nach Vertretung" -#: shifts/templates/shifts/shift_detail.html:191 +#: shifts/templates/shifts/shift_detail.html:193 #, python-format msgid "" "You can only look for\n" @@ -4384,11 +4388,11 @@ msgstr "" " teilnehmen kannst, wende dich bitte schnellstmöglich\n" " an deine Teamleitung." -#: shifts/templates/shifts/shift_detail.html:204 +#: shifts/templates/shifts/shift_detail.html:206 msgid "Look for a stand-in" msgstr "Suche nach Vertretung" -#: shifts/templates/shifts/shift_detail.html:213 +#: shifts/templates/shifts/shift_detail.html:215 #, fuzzy, python-format #| msgid "" #| "You can only unregister\n" @@ -4410,11 +4414,11 @@ msgid "" " possible." msgstr "Du kannst dich nur bis %(NB_DAYS_FOR_SELF_UNREGISTER)s Tage vor deiner Schicht abmelden. Von ABCD-Schichten kannst du dich überhaupt nicht abmelden. Wenn du die Schicht nicht wahrnehmen kannst, suche bitte eine Vertretung oder melde dich schnellstmöglich bei deiner Teamleitung." -#: shifts/templates/shifts/shift_detail.html:228 +#: shifts/templates/shifts/shift_detail.html:230 msgid "Unregister myself" msgstr "Melde mich ab" -#: shifts/templates/shifts/shift_detail.html:233 +#: shifts/templates/shifts/shift_detail.html:235 #, fuzzy #| msgid "" #| "You can only register\n" @@ -4443,12 +4447,12 @@ msgstr "" "- die Schicht in der Zukunft liegt\n" " " -#: shifts/templates/shifts/shift_detail.html:275 +#: shifts/templates/shifts/shift_detail.html:277 #: shifts/templates/shifts/shift_template_detail.html:99 msgid "Not specified" msgstr "" -#: shifts/templates/shifts/shift_detail.html:358 +#: shifts/templates/shifts/shift_detail.html:360 msgid "Edit slot" msgstr "Slot bearbeiten" @@ -4691,32 +4695,32 @@ msgstr "Du beobachtest keine Schichten" msgid "Observed changes:" msgstr "Beobachtete Änderungen" -#: shifts/templates/shifts/shiftwatch_overview.html:75 +#: shifts/templates/shifts/shiftwatch_overview.html:76 msgid "Shift Watches" msgstr "Schicht-Beobachtungen" -#: shifts/templates/shifts/shiftwatch_overview.html:81 +#: shifts/templates/shifts/shiftwatch_overview.html:82 msgid "Total" msgstr "Insgesamt" -#: shifts/templates/shifts/shiftwatch_overview.html:104 +#: shifts/templates/shifts/shiftwatch_overview.html:105 #: shifts/templates/shifts/user_shifts_overview_tag.html:64 msgid "Show more" msgstr "Mehr anzeigen" -#: shifts/templates/shifts/shiftwatch_overview.html:126 +#: shifts/templates/shifts/shiftwatch_overview.html:127 msgid "You can watch single shifts for changes by selecting a shift " msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:127 +#: shifts/templates/shifts/shiftwatch_overview.html:128 msgid "here" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:128 +#: shifts/templates/shifts/shiftwatch_overview.html:129 msgid " and clicking on the Watch-button" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:134 +#: shifts/templates/shifts/shiftwatch_overview.html:135 msgid "Delete Selected" msgstr "Lösche das Ausgewählte" From 35e5c837961b0ee0d8ae06d3a7a662731b5417df Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:53:17 +0200 Subject: [PATCH 23/28] Revert "test_handle_registerAnotherUserToSameCapability_MailSent" This reverts commit 47981885e7672e4605a842dbfc7805eeb30dda57. --- .../tests/test_shiftwatch_notification.py | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index df7a17011..abd388134 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -290,36 +290,3 @@ def test_handle_watchDifferentCapability_noMailSent(self): self.unregister_slot(slot=slot_to_unregister) Command().handle() self.assertEqual(0, len(mail.outbox)) - - def test_handle_registerAnotherUserToSameCapability_MailSent(self): - slot_to_register = ShiftSlot.objects.create( - shift=self.shift_ok_first, name="cheese-making" - ) - self.slots.append(slot_to_register) - for slot in self.slots: - slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] - slot.save() - - self.shift_watch = create_shift_watch( - user=self.user, - shift=self.shift_ok_first, - last_valid_slot_ids=self.slots, - staffing_status=[], - watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], - ) - # TODO assert first and second shift have attendance and third shift has no attendance - Command().handle() - self.assertEqual(0, len(mail.outbox)) - - # register user to third slot - user = TapirUserFactory.create() - ShiftAttendance.objects.create(user=user, slot=slot_to_register) - slot_to_register.save() - - self.assertEqual( - slot_to_register.required_capabilities, - [ShiftUserCapability.SHIFT_COORDINATOR], - ) - - Command().handle() - self.assertEqual(1, len(mail.outbox)) From 49f4b6cf740c90003ae94f09d2ad39114520393f Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:05:11 +0200 Subject: [PATCH 24/28] translation --- .../locale/de/LC_MESSAGES/django.po | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po index 0e17400fd..7b053767e 100644 --- a/tapir/translations/locale/de/LC_MESSAGES/django.po +++ b/tapir/translations/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-06 20:45+0200\n" +"POT-Creation-Date: 2026-07-09 21:53+0200\n" "PO-Revision-Date: 2025-07-07 13:06+0000\n" "Last-Translator: Weblate Admin \n" "Language-Team: German \n" @@ -2703,7 +2703,7 @@ msgstr "Hat Qualifikation" msgid "Does not have qualification" msgstr "Hat die Qualifikation nicht" -#: coop/views/shareowner.py:656 shifts/forms.py:788 +#: coop/views/shareowner.py:656 shifts/forms.py:789 msgid "ABCD Week" msgstr "ABCD-Woche" @@ -3244,36 +3244,40 @@ msgstr "" msgid "I understand that this will delete the shift exemption and create a membership pause" msgstr "" -#: shifts/forms.py:725 shifts/forms.py:793 shifts/views/views.py:419 +#: shifts/forms.py:725 shifts/forms.py:794 shifts/views/views.py:419 #: shifts/views/views.py:420 msgid "Shift changes you would like to be informed about" msgstr "Schicht-Änderungen, bei denen du informiert werden möchtest" -#: shifts/forms.py:732 +#: shifts/forms.py:732 shifts/forms.py:801 msgid "Notify me when these capabilities become available or unavailable" msgstr "" -#: shifts/forms.py:736 +#: shifts/forms.py:736 shifts/forms.py:805 msgid "Get notified when someone with specific skills registers or unregisters" msgstr "" -#: shifts/forms.py:782 shifts/templates/shifts/shift_template_detail.html:7 +#: shifts/forms.py:783 shifts/templates/shifts/shift_template_detail.html:7 #: shifts/templates/shifts/shift_template_detail.html:15 #: shifts/templates/shifts/user_shifts_overview_tag.html:31 #: shifts/templates/shifts/user_shifts_overview_tag.html:43 msgid "ABCD Shift" msgstr "ABCD-Schicht" -#: shifts/forms.py:799 +#: shifts/forms.py:810 #, python-format msgid "If weekdays or %(shift_template_group)s are selected, %(shift_templates)s may not be selected, and vice versa." msgstr "" -#: shifts/forms.py:803 +#: shifts/forms.py:814 #, python-format msgid "At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected." msgstr "" +#: shifts/forms.py:818 +msgid "At least one of the fields staffing_status or required capabilities must be selected." +msgstr "" + #: shifts/models.py:39 msgid "Teamleader" msgstr "Teamleiter*in" @@ -4319,7 +4323,7 @@ msgid "Unwatch" msgstr "Nicht mehr beobachten" #: shifts/templates/shifts/shift_detail.html:49 -#: shifts/templates/shifts/shiftwatch_overview.html:123 +#: shifts/templates/shifts/shiftwatch_overview.html:124 msgid "You will get mail-notifications when shifts you follow change — tailored to the types of updates you choose (e.g., needs help, is full, cancellations)." msgstr "Du erhältst E-Mail-Benachrichtigungen, wenn sich Schichten, denen du folgst, ändern – angepasst an die von dir ausgewählten Arten von Aktualisierungen (z. B. Hilfe benötigt, voll, Stornierungen)." @@ -4741,32 +4745,32 @@ msgstr "Du beobachtest keine Schichten" msgid "Observed changes:" msgstr "Beobachtete Änderungen" -#: shifts/templates/shifts/shiftwatch_overview.html:75 +#: shifts/templates/shifts/shiftwatch_overview.html:76 msgid "Shift Watches" msgstr "Schicht-Beobachtungen" -#: shifts/templates/shifts/shiftwatch_overview.html:81 +#: shifts/templates/shifts/shiftwatch_overview.html:82 msgid "Total" msgstr "Insgesamt" -#: shifts/templates/shifts/shiftwatch_overview.html:104 +#: shifts/templates/shifts/shiftwatch_overview.html:105 #: shifts/templates/shifts/user_shifts_overview_tag.html:64 msgid "Show more" msgstr "Mehr anzeigen" -#: shifts/templates/shifts/shiftwatch_overview.html:126 +#: shifts/templates/shifts/shiftwatch_overview.html:127 msgid "You can watch single shifts for changes by selecting a shift " msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:127 +#: shifts/templates/shifts/shiftwatch_overview.html:128 msgid "here" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:128 +#: shifts/templates/shifts/shiftwatch_overview.html:129 msgid " and clicking on the Watch-button" msgstr "" -#: shifts/templates/shifts/shiftwatch_overview.html:134 +#: shifts/templates/shifts/shiftwatch_overview.html:135 msgid "Delete Selected" msgstr "Lösche das Ausgewählte" From fa9697a102a8910b8063047908f42afa2965ba39 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:13:38 +0200 Subject: [PATCH 25/28] fix: send_shift_watch_mail need reason= --- .../commands/send_understaffed_shift_reminder_mail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tapir/shifts/management/commands/send_understaffed_shift_reminder_mail.py b/tapir/shifts/management/commands/send_understaffed_shift_reminder_mail.py index 5a044e0da..51d95ea28 100644 --- a/tapir/shifts/management/commands/send_understaffed_shift_reminder_mail.py +++ b/tapir/shifts/management/commands/send_understaffed_shift_reminder_mail.py @@ -41,5 +41,5 @@ def handle(self, *args, **options): ) if current_status == StaffingStatusChoices.UNDERSTAFFED: SendShiftWatchCommand.send_shift_watch_mail( - shift_watch_data, staffing_status=StaffingStatusChoices.UNDERSTAFFED + shift_watch_data, reason=StaffingStatusChoices.UNDERSTAFFED ) From 90763ec13b1e3a1e6ede0f3fa1196d68b309dab4 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:15:56 +0200 Subject: [PATCH 26/28] fix typing --- tapir/shifts/services/shift_watch_creation_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tapir/shifts/services/shift_watch_creation_service.py b/tapir/shifts/services/shift_watch_creation_service.py index a62d5801a..8caed0f4b 100644 --- a/tapir/shifts/services/shift_watch_creation_service.py +++ b/tapir/shifts/services/shift_watch_creation_service.py @@ -13,7 +13,7 @@ class ShiftWatchCreator: @classmethod def get_staffing_status_for_shift( - cls, shift: Shift, last_status: str = None + cls, shift: Shift, last_status: str | None = None ) -> str | None: """ Compute the staffing status for a Shift instance by extracting the required @@ -58,7 +58,7 @@ def calculate_staffing_status( number_of_available_slots: int, valid_attendances: int, required_attendances: int, - last_status: str = None, + last_status: str | None = None, ): """Determine the staffing status based on attendance counts. Returns None if status has not changed.""" if valid_attendances < required_attendances: @@ -81,7 +81,7 @@ def get_staffing_status_if_changed( number_of_available_slots: int, valid_attendances: int, required_attendances: int, - last_status: str = None, + last_status: str | None = None, ) -> None | StaffingStatusChoices: """ Determine if the staffing status has changed. Return **None** if the staffing status has not changed. From 37399a14decfe0e42414db405c735cb618ba3253 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:27:58 +0200 Subject: [PATCH 27/28] add watched_capabilities to all tests --- tapir/shifts/tests/test_shiftwatch_creation_service.py | 3 +++ tapir/shifts/tests/test_shiftwatch_notification.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/tapir/shifts/tests/test_shiftwatch_creation_service.py b/tapir/shifts/tests/test_shiftwatch_creation_service.py index a1de24280..92570b18d 100644 --- a/tapir/shifts/tests/test_shiftwatch_creation_service.py +++ b/tapir/shifts/tests/test_shiftwatch_creation_service.py @@ -56,6 +56,7 @@ def test_createShiftWatchesForRecurring_existingShiftWatch_skipsExisting(self): user=self.user, weekdays=[self.base_shift.start_time.weekday()], staffing_status=[StaffingStatusChoices.ALL_CLEAR], + watched_capabilities=[], ) ShiftWatchFactory(user=self.user, shift=self.base_shift) @@ -77,6 +78,7 @@ def test_createShiftWatchForShift_shiftWithoutTemplate_getsAccepted(self): user=self.user, weekdays=[shift.start_time.weekday()], staffing_status=[StaffingStatusChoices.UNDERSTAFFED], + watched_capabilities=[], ) ShiftWatchCreator.create_shift_watches_for_shift_based_on_recurring(shift) @@ -91,6 +93,7 @@ def test_createShiftWatchesForRecurring_RecurringWithoutCriteria_createsNoShiftw user=self.user, weekdays=[], staffing_status=[StaffingStatusChoices.ALL_CLEAR], + watched_capabilities=[], ) # Create two shifts which should not be existing after diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index abd388134..d3c141a40 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -91,6 +91,7 @@ def test_handle_watchedShiftIsUnderstaffed_correctNotificationIsSent(self): shift=self.shift_ok_first, last_valid_slot_ids=self.slots, staffing_status=[StaffingStatusChoices.UNDERSTAFFED], + watched_capabilities=[], ) Command().handle() self.assertEqual(0, len(mail.outbox)) @@ -106,6 +107,7 @@ def test_handle_watchedShiftIsAlright_noNotificationIsSent(self): shift=self.shift_ok_first, last_valid_slot_ids=self.slots, staffing_status=list(get_staffingstatus_choices()), + watched_capabilities=[], ) Command().handle() self.assertEqual(0, len(mail.outbox)) @@ -126,6 +128,7 @@ def test_handle_initialWatchUnderstaffedShift_noInitialMailIsSent(self): shift=shift_understaffed ), staffing_status=list(get_staffingstatus_choices()), + watched_capabilities=[], ) Command().handle() @@ -146,6 +149,7 @@ def test_handle_triggeredMultipleTimes_onlyOneMailIsSent(self): shift=self.shift_ok_first, last_valid_slot_ids=self.slots, staffing_status=[StaffingStatusChoices.UNDERSTAFFED], + watched_capabilities=[], ) self.unregister_slot() @@ -167,6 +171,7 @@ def test_handle_watchedShiftIsCurrentlyRunning_correctNotificationIsSent(self): shift=self.shift_ok_first, last_valid_slot_ids=self.slots, staffing_status=[StaffingStatusChoices.UNDERSTAFFED], + watched_capabilities=[], ) self.unregister_slot() @@ -187,6 +192,7 @@ def test_handle_shiftInThePast_noNotification(self): shift=self.shift_ok_first, last_valid_slot_ids=self.slots, staffing_status=list(get_staffingstatus_choices()), + watched_capabilities=[], ) self.unregister_slot() @@ -202,6 +208,7 @@ def test_handle_recurring_noInitialMailIsSent(self): user=self.user, weekdays=[self.shift_ok_first.start_time.weekday()], staffing_status=[event.value for event in get_staffingstatus_choices()], + watched_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR], ) ShiftWatchCreator.create_shift_watches_for_recurring(recurring=recurring) From c4e63204fbca30bf54d84185471b33a1cf197c5e Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:41:28 +0200 Subject: [PATCH 28/28] create check_staffing_status and check_watched_capabilities --- .../commands/send_shift_watch_mail.py | 87 +++++++++++-------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index 803794682..ee2c6d3de 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -14,6 +14,52 @@ from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator +def check_staffing_status( + shift_watch_data: ShiftWatch, + valid_attendances_count: int, + notification_reasons: list[str], +) -> None: + """Check for staffing status changes and add notifications if needed.""" + if len(shift_watch_data.staffing_status) == 0: + return + + # Determine staffing status + current_status = ShiftWatchCreator.get_staffing_status_if_changed( + number_of_available_slots=shift_watch_data.shift.slots.count(), + valid_attendances=valid_attendances_count, + required_attendances=shift_watch_data.shift.num_required_attendances, + last_status=shift_watch_data.last_staffing_status, + ) + if current_status: + notification_reasons.append(current_status.label) + shift_watch_data.last_staffing_status = current_status + + # General attendance change notifications + if not notification_reasons: + if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids): + notification_reasons.append(StaffingStatusChoices.ATTENDANCE_PLUS.label) + elif valid_attendances_count < len(shift_watch_data.last_valid_slot_ids): + notification_reasons.append(StaffingStatusChoices.ATTENDANCE_MINUS.label) + + +def check_watched_capabilities( + shift_watch_data: ShiftWatch, + this_valid_slot_ids: list, + notification_reasons: list[str], +) -> None: + """Check for watched capability changes and add notifications if needed.""" + if len(shift_watch_data.watched_capabilities) == 0: + return + + capability_notifications = ShiftWatchCreator.get_capability_status_changes( + this_valid_slot_ids=this_valid_slot_ids, + last_valid_slot_ids=shift_watch_data.last_valid_slot_ids, + watched_capabilities=shift_watch_data.watched_capabilities, + ) + if capability_notifications: + notification_reasons.extend(capability_notifications) + + class Command(BaseCommand): help = "Sent to a member when there is a relevant change in shift staffing and the member wants to know about it." @@ -32,40 +78,13 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch) valid_attendances_count = len(this_valid_slot_ids) - if len(shift_watch_data.staffing_status) > 0: - # Determine staffing status - current_status = ShiftWatchCreator.get_staffing_status_if_changed( - number_of_available_slots=shift_watch_data.shift.slots.count(), - valid_attendances=valid_attendances_count, - required_attendances=shift_watch_data.shift.num_required_attendances, - last_status=shift_watch_data.last_staffing_status, - ) - if current_status: - notification_reasons.append(current_status.label) - shift_watch_data.last_staffing_status = current_status - - # General attendance change notifications - if not notification_reasons: - if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids): - notification_reasons.append( - StaffingStatusChoices.ATTENDANCE_PLUS.label - ) - elif valid_attendances_count < len( - shift_watch_data.last_valid_slot_ids - ): - notification_reasons.append( - StaffingStatusChoices.ATTENDANCE_MINUS.label - ) - - if len(shift_watch_data.watched_capabilities) > 0: - # Check watched capabilities - capability_notifications = ShiftWatchCreator.get_capability_status_changes( - this_valid_slot_ids=this_valid_slot_ids, - last_valid_slot_ids=shift_watch_data.last_valid_slot_ids, - watched_capabilities=shift_watch_data.watched_capabilities, - ) - if capability_notifications: - notification_reasons.extend(capability_notifications) + check_staffing_status( + shift_watch_data, valid_attendances_count, notification_reasons + ) + + check_watched_capabilities( + shift_watch_data, this_valid_slot_ids, notification_reasons + ) for reason in notification_reasons: self.send_shift_watch_mail(shift_watch=shift_watch_data, reason=reason)