From 1ab41142b6504a775d6b2ed19761caf33ecce933 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:47:39 +0100 Subject: [PATCH 01/14] init --- tapir/shifts/forms.py | 14 +++- .../commands/send_shift_watch_mail.py | 72 +++++++++++++------ .../0074_shiftwatch_watched_capabilities.py | 39 ++++++++++ tapir/shifts/models.py | 24 +++++-- 4 files changed, 120 insertions(+), 29 deletions(-) create mode 100644 tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py diff --git a/tapir/shifts/forms.py b/tapir/shifts/forms.py index 8fc2ee0b..1ed3e1a4 100644 --- a/tapir/shifts/forms.py +++ b/tapir/shifts/forms.py @@ -718,7 +718,7 @@ def get_shift_object(self) -> Shift | ShiftTemplate: class ShiftWatchForm(forms.ModelForm): class Meta: model = ShiftWatch - fields = ["staffing_status"] + fields = ["staffing_status", "watched_capabilities"] staffing_status = forms.MultipleChoiceField( required=False, @@ -727,6 +727,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" + ), + ) def __init__(self, *args, **kwargs): request_user: TapirUser = kwargs.pop("request_user") @@ -737,8 +747,10 @@ def __init__(self, *args, **kwargs): ) if last_shiftwatch: self.initial["staffing_status"] = last_shiftwatch.staffing_status + self.initial["watched_capabilities"] = last_shiftwatch.watched_capabilities else: self.initial["staffing_status"] = get_staffingstatus_defaults() + self.initial["watched_capabilities"] = [] class ShiftTemplateField(ModelMultipleChoiceField): diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index 0be2dbb8..ec0973fd 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -12,28 +12,50 @@ StaffingStatusChoices, ShiftUserCapability, ShiftSlot, - ShiftAttendance, + SHIFT_USER_CAPABILITY_CHOICES, ) from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator -def get_shift_coordinator_status( - this_valid_slot_ids: list[int], last_valid_slot_ids: list[int] -): - # Team-leader/Shift-Coordinator notifications - def is_shift_coordinator_available(slot_ids: list[int]): - return ShiftSlot.objects.filter( - id__in=slot_ids, - required_capabilities__contains=[ShiftUserCapability.SHIFT_COORDINATOR], +def get_capability_status_changes( + this_valid_slot_ids: list[int], + last_valid_slot_ids: list[int], + watched_capabilities: list[str], +) -> list[SHIFT_USER_CAPABILITY_CHOICES]: + if not watched_capabilities: + return [] + + notifications = [] + + current_slots = ShiftSlot.objects.filter(id__in=this_valid_slot_ids).values_list( + "id", "required_capabilities" + ) + + last_slots = ( + ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list( + "id", "required_capabilities" ) + if last_valid_slot_ids + else [] + ) + + current_capabilities_set = set() + for slot_id, capabilities in current_slots: + current_capabilities_set.update(capabilities) + + last_capabilities_set = set() + for slot_id, capabilities in last_slots: + last_capabilities_set.update(capabilities) - this_sc_available = is_shift_coordinator_available(this_valid_slot_ids) - last_sc_available = is_shift_coordinator_available(last_valid_slot_ids) - if not this_sc_available and last_sc_available: - return StaffingStatusChoices.SHIFT_COORDINATOR_MINUS - elif this_sc_available and not last_sc_available: - return StaffingStatusChoices.SHIFT_COORDINATOR_PLUS - return None + for capability in watched_capabilities: + has_now = capability in current_capabilities_set + had_before = capability in last_capabilities_set + + if has_now and not had_before: + notifications.append(StaffingStatusChoices.ATTENDANCE_PLUS) + elif not has_now and had_before: + notifications.append(StaffingStatusChoices.ATTENDANCE_PLUS) + return notifications class Command(BaseCommand): @@ -69,12 +91,18 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch) notification_reasons.append(current_status) shift_watch_data.last_staffing_status = current_status - # Check shift coordinator status - shift_coordinator_status = get_shift_coordinator_status( - this_valid_slot_ids, shift_watch_data.last_valid_slot_ids - ) - if shift_coordinator_status is not None: - notification_reasons.append(shift_coordinator_status) + # Check watched capabilities + if shift_watch_data.watched_capabilities: + + capability_notifications = 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, + ) + + # Füge Benachrichtigungen hinzu + for status_enum in capability_notifications: + notification_reasons.append(status_enum) # General attendance change notifications if not notification_reasons: diff --git a/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py b/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py new file mode 100644 index 00000000..7fcddf1d --- /dev/null +++ b/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.12 on 2026-03-13 15:58 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("shifts", "0073_alter_shiftwatch_last_staffing_status"), + ] + + operations = [ + migrations.AddField( + model_name="shiftwatch", + name="watched_capabilities", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("shift_coordinator", "Teamleader"), + ("cashier", "Cashier"), + ("member_office", "Member Office"), + ("bread_delivery", "Bread Delivery"), + ("red_card", "Red Card"), + ("first_aid", "First Aid"), + ("welcome_session", "Welcome Session"), + ("handling_cheese", "Handling Cheese"), + ("train_cheese_handlers", "Train cheese handlers"), + ("inventory", "Inventory"), + ("nebenan_de_support", "Nebenan.de-Support"), + ], + max_length=128, + ), + blank=True, + default=list, + size=None, + ), + ), + ] diff --git a/tapir/shifts/models.py b/tapir/shifts/models.py index a08268a6..92942e37 100644 --- a/tapir/shifts/models.py +++ b/tapir/shifts/models.py @@ -1215,12 +1215,15 @@ class StaffingStatusChoices(models.TextChoices): ATTENDANCE_MINUS = "SLOTS_MINUS", _( "One attendance or more un-registered, but the shift is neither understaffed nor full or almost full." ) - SHIFT_COORDINATOR_MINUS = "SHIFT_COORDINATOR_MINUS", _( - "The Shift Coordinator has unregistered" - ) - SHIFT_COORDINATOR_PLUS = "SHIFT_COORDINATOR_PLUS", _( - "A Shift Coordinator has registered" - ) + # SHIFT_COORDINATOR_MINUS = "SHIFT_COORDINATOR_MINUS", _( + # "The Shift Coordinator has unregistered" + # ) + # SHIFT_COORDINATOR_PLUS = "SHIFT_COORDINATOR_PLUS", _( + # "A Shift Coordinator has registered" + # ) + + # CAPABILITY_PLUS = "capability_plus_{}", _("Capability {} available") + # CAPABILITY_MINUS = "capability_minus_{}", _("Capability {} unavailable") def get_staffingstatus_choices(): @@ -1262,6 +1265,15 @@ class ShiftWatch(models.Model): on_delete=models.CASCADE, ) + watched_capabilities = ArrayField( + models.CharField( + max_length=128, choices=SHIFT_USER_CAPABILITY_CHOICES.items(), blank=False + ), + default=list, + blank=True, + null=False, + ) + def __str__(self): shift_name = self.shift.get_display_name() shift_url = self.shift.get_absolute_url() From 9bbc533646b1f5c5bf3302c4d59470350b20ceec Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:36:41 +0100 Subject: [PATCH 02/14] give ShiftWatchEmailBuilder reason as string not StaffingStatusChoices to be flexible with capabilities --- tapir/shifts/emails/shift_watch_mail.py | 8 ++-- .../commands/send_shift_watch_mail.py | 39 +++++++------------ 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/tapir/shifts/emails/shift_watch_mail.py b/tapir/shifts/emails/shift_watch_mail.py index 848237f2..97633352 100644 --- a/tapir/shifts/emails/shift_watch_mail.py +++ b/tapir/shifts/emails/shift_watch_mail.py @@ -2,7 +2,6 @@ from django.utils.translation import gettext_lazy as _ - from tapir.coop.models import ShareOwner from tapir.core.mail_option import MailOption from tapir.core.tapir_email_builder_base import TapirEmailBuilderBase @@ -12,10 +11,10 @@ class ShiftWatchEmailBuilder(TapirEmailBuilderBase): option = MailOption.OPTIONAL_ENABLED - def __init__(self, shift_watch: ShiftWatch, staffing_status: StaffingStatusChoices): + def __init__(self, shift_watch: ShiftWatch, reason: str): super().__init__() self.shift = shift_watch.shift - self.reason = f"{staffing_status.label}: {shift_watch.shift.get_display_name()}" + self.reason = f"{reason}: {shift_watch.shift.get_display_name()}" @classmethod def get_unique_id(cls) -> str: @@ -53,7 +52,8 @@ def get_dummy_version(cls) -> TapirEmailBuilderBase | None: if not share_owner or not shift_watch: return None mail = cls( - shift_watch=shift_watch, staffing_status=StaffingStatusChoices.UNDERSTAFFED + shift_watch=shift_watch, + reason=StaffingStatusChoices.UNDERSTAFFED.label, ) mail.get_full_context( share_owner=share_owner, diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index ec0973fd..d0782079 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -10,9 +10,7 @@ from tapir.shifts.models import ( ShiftWatch, StaffingStatusChoices, - ShiftUserCapability, ShiftSlot, - SHIFT_USER_CAPABILITY_CHOICES, ) from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator @@ -21,7 +19,7 @@ def get_capability_status_changes( this_valid_slot_ids: list[int], last_valid_slot_ids: list[int], watched_capabilities: list[str], -) -> list[SHIFT_USER_CAPABILITY_CHOICES]: +) -> list[str]: if not watched_capabilities: return [] @@ -52,9 +50,9 @@ def get_capability_status_changes( had_before = capability in last_capabilities_set if has_now and not had_before: - notifications.append(StaffingStatusChoices.ATTENDANCE_PLUS) + notifications.append(f"Member with capability added: {capability}") elif not has_now and had_before: - notifications.append(StaffingStatusChoices.ATTENDANCE_PLUS) + notifications.append(f"Member with capability unregistered: {capability}") return notifications @@ -63,12 +61,12 @@ class Command(BaseCommand): def handle(self, *args, **options): for shift_watch_data in ShiftWatch.objects.filter( - shift__end_time__gte=timezone.now() # end_time not start_time because flexible-shifts can be running the whole day + shift__end_time__gte=timezone.now() ).select_related("user", "shift"): self.send_shift_watch_mail_per_user_and_shift(shift_watch_data) def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch): - notification_reasons: list[StaffingStatusChoices] = [] + notification_reasons: list[str] = [] this_valid_slot_ids = ShiftWatchCreator.get_valid_slot_ids( shift_watch_data.shift @@ -88,47 +86,38 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch) last_status=shift_watch_data.last_staffing_status, ) if current_status: - notification_reasons.append(current_status) + notification_reasons.append(current_status.label) shift_watch_data.last_staffing_status = current_status # Check watched capabilities if shift_watch_data.watched_capabilities: - capability_notifications = 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, ) - - # Füge Benachrichtigungen hinzu - for status_enum in capability_notifications: - notification_reasons.append(status_enum) + notification_reasons.extend(capability_notifications) # General attendance change notifications if not notification_reasons: - # If no other status like "Understaffed" or "teamleader registered" appeared, inform user about general change if valid_attendances_count > len(shift_watch_data.last_valid_slot_ids): - notification_reasons.append(StaffingStatusChoices.ATTENDANCE_PLUS) + 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) + notification_reasons.append( + StaffingStatusChoices.ATTENDANCE_MINUS.label + ) - # Send notifications for reason in notification_reasons: - if reason.value in shift_watch_data.staffing_status: - self.send_shift_watch_mail( - shift_watch=shift_watch_data, staffing_status=reason - ) + self.send_shift_watch_mail(shift_watch=shift_watch_data, reason=reason) shift_watch_data.last_valid_slot_ids = this_valid_slot_ids shift_watch_data.save() @staticmethod - def send_shift_watch_mail( - shift_watch: ShiftWatch, staffing_status: StaffingStatusChoices - ): + def send_shift_watch_mail(shift_watch: ShiftWatch, reason: str): email_builder = ShiftWatchEmailBuilder( shift_watch=shift_watch, - staffing_status=staffing_status, + reason=reason, ) SendMailService.send_to_tapir_user( actor=None, From d75d11bacf9223d2a746c783d2809f4949641946 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:37:52 +0100 Subject: [PATCH 03/14] remove comment --- tapir/shifts/models.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tapir/shifts/models.py b/tapir/shifts/models.py index 92942e37..19a6b93b 100644 --- a/tapir/shifts/models.py +++ b/tapir/shifts/models.py @@ -1215,15 +1215,6 @@ class StaffingStatusChoices(models.TextChoices): ATTENDANCE_MINUS = "SLOTS_MINUS", _( "One attendance or more un-registered, but the shift is neither understaffed nor full or almost full." ) - # SHIFT_COORDINATOR_MINUS = "SHIFT_COORDINATOR_MINUS", _( - # "The Shift Coordinator has unregistered" - # ) - # SHIFT_COORDINATOR_PLUS = "SHIFT_COORDINATOR_PLUS", _( - # "A Shift Coordinator has registered" - # ) - - # CAPABILITY_PLUS = "capability_plus_{}", _("Capability {} available") - # CAPABILITY_MINUS = "capability_minus_{}", _("Capability {} unavailable") def get_staffingstatus_choices(): From f686c242fb08fc9bdbcfc2f742a7bdad2887a27e Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:45:42 +0100 Subject: [PATCH 04/14] remove test_handle_watchingCoordinatorChanges_SHIFT_COORDINATOR_PLUSGetSent --- .../tests/test_shiftwatch_notification.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/tapir/shifts/tests/test_shiftwatch_notification.py b/tapir/shifts/tests/test_shiftwatch_notification.py index 76ff2178..45a72676 100644 --- a/tapir/shifts/tests/test_shiftwatch_notification.py +++ b/tapir/shifts/tests/test_shiftwatch_notification.py @@ -101,28 +101,6 @@ def test_handle_watchedShiftIsAlright_noNotificationIsSent(self): Command().handle() self.assertEqual(0, len(mail.outbox)) - def test_handle_watchingCoordinatorChanges_SHIFT_COORDINATOR_PLUSGetSent(self): - self.shift_watch = create_shift_watch( - user=self.user, - shift=self.shift_ok_first, - slots=self.slots, - staffing_status=[StaffingStatusChoices.SHIFT_COORDINATOR_PLUS.value], - ) - - slot = ShiftSlot.objects.filter( - shift=self.shift_ok_first, attendances__isnull=True - ).first() - slot.required_capabilities = [ShiftUserCapability.SHIFT_COORDINATOR] - slot.save() - - Command().handle() - self.assertEqual(0, len(mail.outbox)) - - # Register teamleader - ShiftAttendance.objects.create(user=TapirUserFactory.create(), slot=slot) - Command().handle() - self.assert_email_sent(StaffingStatusChoices.SHIFT_COORDINATOR_PLUS) - def test_handle_initialWatchUnderstaffedShift_noInitialMailIsSent(self): # No initial message should be sent, even if the shift is understaffed user = TapirUserFactory.create(email=self.USER_EMAIL_ADDRESS) From f523bf04d536bdc793ac5904a13886f773233aa1 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:49:26 +0100 Subject: [PATCH 05/14] transl --- .../locale/de/LC_MESSAGES/django.po | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po index d7f5e375..7b7395f4 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-03-11 07:46+0100\n" +"POT-Creation-Date: 2026-03-13 18:47+0100\n" "PO-Revision-Date: 2025-07-07 13:06+0000\n" "Last-Translator: Weblate Admin \n" "Language-Team: German \n" @@ -2563,7 +2563,7 @@ msgstr "Hat Qualifikation" msgid "Does not have qualification" msgstr "Hat die Qualifikation nicht" -#: coop/views/shareowner.py:647 shifts/forms.py:777 +#: coop/views/shareowner.py:647 shifts/forms.py:789 msgid "ABCD Week" msgstr "ABCD-Woche" @@ -2957,11 +2957,11 @@ msgstr "Wird an ein Mitglied gesendet, wenn das Mitgliederbüro die Schicht als msgid "Shift reminder" msgstr "Schicht-Erinnerung" -#: shifts/emails/shift_watch_mail.py:26 +#: shifts/emails/shift_watch_mail.py:25 msgid "Watched Shift has changed" msgstr "Die beobachtete Schicht hat sich geändert" -#: shifts/emails/shift_watch_mail.py:31 +#: shifts/emails/shift_watch_mail.py:30 msgid "Sent to a member when a shift staffing is changed relevantely and the user is watching this shift." msgstr "Wird an ein Mitglied gesendet, wenn sich die Personalbesetzung einer Schicht relevant ändert und der Benutzer diese Schicht beobachtet." @@ -3092,24 +3092,32 @@ msgstr "" msgid "I understand that this will delete the shift exemption and create a membership pause" msgstr "" -#: shifts/forms.py:726 shifts/forms.py:782 shifts/views/views.py:379 +#: shifts/forms.py:726 shifts/forms.py:794 shifts/views/views.py:379 #: shifts/views/views.py:380 msgid "Shift changes you would like to be informed about" msgstr "Schicht-Änderungen, bei denen du informiert werden möchtest" -#: shifts/forms.py:771 shifts/templates/shifts/shift_template_detail.html:7 +#: shifts/forms.py:733 +msgid "Notify me when these capabilities become available or unavailable" +msgstr "" + +#: shifts/forms.py:737 +msgid "Get notified when someone with specific skills registers or unregisters" +msgstr "" + +#: 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:788 +#: shifts/forms.py:800 #, 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:792 +#: shifts/forms.py:804 #, python-format msgid "At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected." msgstr "" @@ -3285,14 +3293,6 @@ msgstr "Ein Mitglied oder mehr hat sich registriert, aber die Schicht ist weder msgid "One attendance or more un-registered, but the shift is neither understaffed nor full or almost full." msgstr "Ein Mitglied oder mehr hat sich abgemeldet, aber die Schicht ist weder unterbesetzt noch (fast) voll" -#: shifts/models.py:1219 -msgid "The Shift Coordinator has unregistered" -msgstr "Ein Teamleiter hat sich abgemeldet" - -#: shifts/models.py:1222 -msgid "A Shift Coordinator has registered" -msgstr "Ein Teamleiter hat sich angemeldet" - #: shifts/templates/shifts/cancel_shift.html:7 msgid "Cancel shift:" msgstr "Schicht absagen:" @@ -6334,6 +6334,12 @@ msgstr "%(name)s ist kein Mitglied der Genossenschaft. Vielleicht haben sie dere msgid "%(name)s has not attended a welcome session yet. Make sure they plan to do it!" msgstr "%(name)s hat an dem Willkommenstreffen noch nicht teilgenommen. Stelle sicher, dass er*sie es entsprechend einplant!" +#~ msgid "The Shift Coordinator has unregistered" +#~ msgstr "Ein Teamleiter hat sich abgemeldet" + +#~ msgid "A Shift Coordinator has registered" +#~ msgstr "Ein Teamleiter hat sich angemeldet" + #~ msgid "About tapir" #~ msgstr "Über Tapir" From 239a90a49b371522fd4a5eae1632249079d231f7 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:00:24 +0100 Subject: [PATCH 06/14] choices as list --- .../0074_shiftwatch_watched_capabilities.py | 17 +++-------------- tapir/shifts/models.py | 6 +++++- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py b/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py index 7fcddf1d..29301ae0 100644 --- a/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py +++ b/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py @@ -1,6 +1,7 @@ -# Generated by Django 5.2.12 on 2026-03-13 15:58 +# Generated by Django 5.2.12 on 2026-03-13 21:00 import django.contrib.postgres.fields +import tapir.shifts.models from django.db import migrations, models @@ -16,19 +17,7 @@ class Migration(migrations.Migration): name="watched_capabilities", field=django.contrib.postgres.fields.ArrayField( base_field=models.CharField( - choices=[ - ("shift_coordinator", "Teamleader"), - ("cashier", "Cashier"), - ("member_office", "Member Office"), - ("bread_delivery", "Bread Delivery"), - ("red_card", "Red Card"), - ("first_aid", "First Aid"), - ("welcome_session", "Welcome Session"), - ("handling_cheese", "Handling Cheese"), - ("train_cheese_handlers", "Train cheese handlers"), - ("inventory", "Inventory"), - ("nebenan_de_support", "Nebenan.de-Support"), - ], + choices=tapir.shifts.models.get_shift_capability_choices, max_length=128, ), blank=True, diff --git a/tapir/shifts/models.py b/tapir/shifts/models.py index 19a6b93b..d98048de 100644 --- a/tapir/shifts/models.py +++ b/tapir/shifts/models.py @@ -1225,6 +1225,10 @@ def get_staffingstatus_defaults(): return [StaffingStatusChoices.FULL, StaffingStatusChoices.UNDERSTAFFED] +def get_shift_capability_choices(): + return list(SHIFT_USER_CAPABILITY_CHOICES.items()) + + class ShiftWatch(models.Model): user = models.ForeignKey( TapirUser, related_name="user_watching_shift", on_delete=models.CASCADE @@ -1258,7 +1262,7 @@ class ShiftWatch(models.Model): watched_capabilities = ArrayField( models.CharField( - max_length=128, choices=SHIFT_USER_CAPABILITY_CHOICES.items(), blank=False + max_length=128, choices=get_shift_capability_choices, blank=False ), default=list, blank=True, From 707779acffa0d9575f4cbe6193a15401883aaec0 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:12:58 +0100 Subject: [PATCH 07/14] remove unneeded if --- .../management/commands/send_shift_watch_mail.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index d0782079..7dea429d 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -90,13 +90,12 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch) shift_watch_data.last_staffing_status = current_status # Check watched capabilities - if shift_watch_data.watched_capabilities: - capability_notifications = 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) + capability_notifications = 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: From 77ab656405441c23d16a715bb0fc80d320e99450 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:37:16 +0100 Subject: [PATCH 08/14] remove unused "id" --- tapir/shifts/management/commands/send_shift_watch_mail.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index 7dea429d..50c8b908 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -26,23 +26,23 @@ def get_capability_status_changes( notifications = [] current_slots = ShiftSlot.objects.filter(id__in=this_valid_slot_ids).values_list( - "id", "required_capabilities" + "required_capabilities", flat=True ) last_slots = ( ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list( - "id", "required_capabilities" + "required_capabilities", flat=True ) if last_valid_slot_ids else [] ) current_capabilities_set = set() - for slot_id, capabilities in current_slots: + for capabilities in current_slots: current_capabilities_set.update(capabilities) last_capabilities_set = set() - for slot_id, capabilities in last_slots: + for capabilities in last_slots: last_capabilities_set.update(capabilities) for capability in watched_capabilities: From ba5eec1872ddf53c1d1c47d7e21f988947f3c033 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:38:06 +0100 Subject: [PATCH 09/14] last_valid_slot_ids can't be None --- tapir/shifts/management/commands/send_shift_watch_mail.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index 50c8b908..aa19c907 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -29,12 +29,8 @@ def get_capability_status_changes( "required_capabilities", flat=True ) - last_slots = ( - ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list( - "required_capabilities", flat=True - ) - if last_valid_slot_ids - else [] + last_slots = ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list( + "required_capabilities", flat=True ) current_capabilities_set = set() From 53eeea637f9f87b6227e588723198a22c9de009d Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:08:00 +0100 Subject: [PATCH 10/14] update migreation --- .../0074_shiftwatch_watched_capabilities.py | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py diff --git a/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py b/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py deleted file mode 100644 index 29301ae0..00000000 --- a/tapir/shifts/migrations/0074_shiftwatch_watched_capabilities.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-13 21:00 - -import django.contrib.postgres.fields -import tapir.shifts.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("shifts", "0073_alter_shiftwatch_last_staffing_status"), - ] - - operations = [ - migrations.AddField( - model_name="shiftwatch", - 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 4c9dd0aaefd40265b0418466a628d38122325f4b Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:41:30 +0100 Subject: [PATCH 11/14] move get_capability_status_changes to ShiftWatchCreator --- .../commands/send_shift_watch_mail.py | 40 +------------------ .../services/shift_watch_creation_service.py | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/tapir/shifts/management/commands/send_shift_watch_mail.py b/tapir/shifts/management/commands/send_shift_watch_mail.py index 4ba5e945..62bf05ac 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -9,48 +9,10 @@ from tapir.shifts.models import ( ShiftWatch, StaffingStatusChoices, - ShiftSlot, ) from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator -def get_capability_status_changes( - this_valid_slot_ids: list[int], - last_valid_slot_ids: list[int], - watched_capabilities: list[str], -) -> list[str]: - if not watched_capabilities: - return [] - - notifications = [] - - current_slots = ShiftSlot.objects.filter(id__in=this_valid_slot_ids).values_list( - "required_capabilities", flat=True - ) - - last_slots = ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list( - "required_capabilities", flat=True - ) - - current_capabilities_set = set() - for capabilities in current_slots: - current_capabilities_set.update(capabilities) - - last_capabilities_set = set() - for capabilities in last_slots: - last_capabilities_set.update(capabilities) - - for capability in watched_capabilities: - has_now = capability in current_capabilities_set - had_before = capability in last_capabilities_set - - if has_now and not had_before: - notifications.append(f"Member with capability added: {capability}") - elif not has_now and had_before: - notifications.append(f"Member with capability unregistered: {capability}") - return 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." @@ -83,7 +45,7 @@ def send_shift_watch_mail_per_user_and_shift(self, shift_watch_data: ShiftWatch) shift_watch_data.last_staffing_status = current_status # Check watched capabilities - capability_notifications = get_capability_status_changes( + 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, diff --git a/tapir/shifts/services/shift_watch_creation_service.py b/tapir/shifts/services/shift_watch_creation_service.py index 43c9772e..2d075d6c 100644 --- a/tapir/shifts/services/shift_watch_creation_service.py +++ b/tapir/shifts/services/shift_watch_creation_service.py @@ -197,3 +197,43 @@ def create_shift_watches_for_shift_based_on_recurring(cls, shift: Shift) -> None if new_watches: ShiftWatch.objects.bulk_create(new_watches) + + @classmethod + def get_capability_status_changes( + cls, + this_valid_slot_ids: list[int], + last_valid_slot_ids: list[int], + watched_capabilities: list[str], + ) -> list[str]: + if not watched_capabilities: + return [] + + notifications = [] + + current_slots = ShiftSlot.objects.filter( + id__in=this_valid_slot_ids + ).values_list("required_capabilities", flat=True) + + last_slots = ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list( + "required_capabilities", flat=True + ) + + current_capabilities_set = set() + for capabilities in current_slots: + current_capabilities_set.update(capabilities) + + last_capabilities_set = set() + for capabilities in last_slots: + last_capabilities_set.update(capabilities) + + for capability in watched_capabilities: + has_now = capability in current_capabilities_set + had_before = capability in last_capabilities_set + + if has_now and not had_before: + notifications.append(f"Member with capability added: {capability}") + elif not has_now and had_before: + notifications.append( + f"Member with capability unregistered: {capability}" + ) + return notifications From b8c044cdef58e50755df4050ba9c182c6ca43622 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:43:10 +0100 Subject: [PATCH 12/14] init test_get_capability_status_change.py --- .../test_get_capability_status_change.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tapir/shifts/tests/test_get_capability_status_change.py diff --git a/tapir/shifts/tests/test_get_capability_status_change.py b/tapir/shifts/tests/test_get_capability_status_change.py new file mode 100644 index 00000000..f93eae8c --- /dev/null +++ b/tapir/shifts/tests/test_get_capability_status_change.py @@ -0,0 +1,96 @@ +import pytest + +from tapir.shifts.models import ( + ShiftSlot, + SHIFT_USER_CAPABILITY_CHOICES, + ShiftUserCapability, +) +from tapir.shifts.services.shift_watch_creation_service import ShiftWatchCreator +from tapir.shifts.tests.factories import ( + ShiftSlotFactory, + ShiftFactory, + ShiftWatchFactory, +) + + +@pytest.fixture(scope="function") +def set_up_slots(db): + shift = ShiftFactory.create(nb_slots=0) + slot_1 = ShiftSlotFactory.create( + id=1, shift=shift, required_capabilities=[ShiftUserCapability.SHIFT_COORDINATOR] + ) + slot_2 = ShiftSlotFactory.create(id=2, shift=shift, required_capabilities=[]) + slot_3 = ShiftSlotFactory.create( + id=3, shift=shift, required_capabilities=[ShiftUserCapability.HANDLING_CHEESE] + ) + # ShiftWatchFactory.create(shift=shift) + return { + "slot_1": slot_1.id, + "slot_2": slot_2.id, + "slot_3": slot_3.id, + } + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "this_valid_slot_ids, last_valid_slot_ids, watched_capabilities, expected_notifications", + [ + ([], [], [], []), + ( + [1, 2], + [2], + [ShiftUserCapability.SHIFT_COORDINATOR], + [f"Member with capability added: {ShiftUserCapability.SHIFT_COORDINATOR}"], + ), + ( + [2], + [1, 2], + [ShiftUserCapability.SHIFT_COORDINATOR], + [ + f"Member with capability unregistered: {ShiftUserCapability.SHIFT_COORDINATOR}" + ], + ), + ([1, 2], [1, 2], [{ShiftUserCapability.SHIFT_COORDINATOR}], []), + ( + [3], + [2], + [ShiftUserCapability.HANDLING_CHEESE], + [f"Member with capability added: {ShiftUserCapability.HANDLING_CHEESE}"], + ), + ( + [3], + [2], + [ShiftUserCapability.SHIFT_COORDINATOR], + [], + ), # only should trigger when HANDLING_CHEESE is watched + ( + [2], + [3], + [ShiftUserCapability.HANDLING_CHEESE], + [ + f"Member with capability unregistered: {ShiftUserCapability.HANDLING_CHEESE}" + ], + ), + ], +) +def test_get_capability_status_changes( + set_up_slots, + this_valid_slot_ids, + last_valid_slot_ids, + watched_capabilities, + expected_notifications, +): + print( + ShiftSlot.objects.filter(id__in=this_valid_slot_ids).values_list( + "required_capabilities", flat=True + ) + ) + print( + ShiftSlot.objects.filter(id__in=last_valid_slot_ids).values_list( + "required_capabilities", flat=True + ) + ) + result = ShiftWatchCreator.get_capability_status_changes( + this_valid_slot_ids, last_valid_slot_ids, watched_capabilities + ) + assert result == expected_notifications From db48dd66e323b10a0adea1b3fefbbadf6a14c3e1 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:45:51 +0100 Subject: [PATCH 13/14] add migration --- .../0075_shiftwatch_watched_capabilities.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tapir/shifts/migrations/0075_shiftwatch_watched_capabilities.py diff --git a/tapir/shifts/migrations/0075_shiftwatch_watched_capabilities.py b/tapir/shifts/migrations/0075_shiftwatch_watched_capabilities.py new file mode 100644 index 00000000..5ef53655 --- /dev/null +++ b/tapir/shifts/migrations/0075_shiftwatch_watched_capabilities.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.12 on 2026-03-17 06:48 + +import django.contrib.postgres.fields +import tapir.shifts.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("shifts", "0074_alter_shift_num_required_attendances"), + ] + + operations = [ + migrations.AddField( + model_name="shiftwatch", + 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 2e464a44301194f3d119edf65d7554226743000a Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:46:15 +0100 Subject: [PATCH 14/14] translation --- .../locale/de/LC_MESSAGES/django.po | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po index cd6b9f55..696c7718 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-03-15 12:03+0100\n" +"POT-Creation-Date: 2026-03-17 18:45+0100\n" "PO-Revision-Date: 2025-07-07 13:06+0000\n" "Last-Translator: Weblate Admin \n" "Language-Team: German \n" @@ -2563,7 +2563,7 @@ msgstr "Hat Qualifikation" msgid "Does not have qualification" msgstr "Hat die Qualifikation nicht" -#: coop/views/shareowner.py:647 shifts/forms.py:777 +#: coop/views/shareowner.py:647 shifts/forms.py:789 msgid "ABCD Week" msgstr "ABCD-Woche" @@ -2957,11 +2957,11 @@ msgstr "Wird an ein Mitglied gesendet, wenn das Mitgliederbüro die Schicht als msgid "Shift reminder" msgstr "Schicht-Erinnerung" -#: shifts/emails/shift_watch_mail.py:26 +#: shifts/emails/shift_watch_mail.py:25 msgid "Watched Shift has changed" msgstr "Die beobachtete Schicht hat sich geändert" -#: shifts/emails/shift_watch_mail.py:31 +#: shifts/emails/shift_watch_mail.py:30 msgid "Sent to a member when a shift staffing is changed relevantely and the user is watching this shift." msgstr "Wird an ein Mitglied gesendet, wenn sich die Personalbesetzung einer Schicht relevant ändert und der Benutzer diese Schicht beobachtet." @@ -3092,24 +3092,32 @@ msgstr "" msgid "I understand that this will delete the shift exemption and create a membership pause" msgstr "" -#: shifts/forms.py:726 shifts/forms.py:782 shifts/views/views.py:379 +#: shifts/forms.py:726 shifts/forms.py:794 shifts/views/views.py:379 #: shifts/views/views.py:380 msgid "Shift changes you would like to be informed about" msgstr "Schicht-Änderungen, bei denen du informiert werden möchtest" -#: shifts/forms.py:771 shifts/templates/shifts/shift_template_detail.html:7 +#: shifts/forms.py:733 +msgid "Notify me when these capabilities become available or unavailable" +msgstr "" + +#: shifts/forms.py:737 +msgid "Get notified when someone with specific skills registers or unregisters" +msgstr "" + +#: 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:788 +#: shifts/forms.py:800 #, 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:792 +#: shifts/forms.py:804 #, python-format msgid "At least one of the fields (%(shift_templates)s, weekdays, or %(shift_template_group)s) must be selected." msgstr "" @@ -3285,14 +3293,6 @@ msgstr "Ein Mitglied oder mehr hat sich registriert, aber die Schicht ist weder msgid "One attendance or more un-registered, but the shift is neither understaffed nor full or almost full." msgstr "Ein Mitglied oder mehr hat sich abgemeldet, aber die Schicht ist weder unterbesetzt noch (fast) voll" -#: shifts/models.py:1210 -msgid "The Shift Coordinator has unregistered" -msgstr "Ein Teamleiter hat sich abgemeldet" - -#: shifts/models.py:1213 -msgid "A Shift Coordinator has registered" -msgstr "Ein Teamleiter hat sich angemeldet" - #: shifts/templates/shifts/cancel_shift.html:7 msgid "Cancel shift:" msgstr "Schicht absagen:" @@ -6382,6 +6382,12 @@ msgstr "%(name)s ist kein Mitglied der Genossenschaft. Vielleicht haben sie dere msgid "%(name)s has not attended a welcome session yet. Make sure they plan to do it!" msgstr "%(name)s hat an dem Willkommenstreffen noch nicht teilgenommen. Stelle sicher, dass er*sie es entsprechend einplant!" +#~ msgid "The Shift Coordinator has unregistered" +#~ msgstr "Ein Teamleiter hat sich abgemeldet" + +#~ msgid "A Shift Coordinator has registered" +#~ msgstr "Ein Teamleiter hat sich angemeldet" + #~ msgid "About tapir" #~ msgstr "Über Tapir"