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/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 563d1105..62bf05ac 100644 --- a/tapir/shifts/management/commands/send_shift_watch_mail.py +++ b/tapir/shifts/management/commands/send_shift_watch_mail.py @@ -9,42 +9,21 @@ from tapir.shifts.models import ( ShiftWatch, StaffingStatusChoices, - ShiftUserCapability, - ShiftSlot, ) 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], - ) - - 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 - - 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." 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 @@ -62,41 +41,37 @@ 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 shift coordinator status - shift_coordinator_status = get_shift_coordinator_status( - this_valid_slot_ids, shift_watch_data.last_valid_slot_ids + # 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 shift_coordinator_status is not None: - notification_reasons.append(shift_coordinator_status) + 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, 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, + ), + ), + ] diff --git a/tapir/shifts/models.py b/tapir/shifts/models.py index b639b977..bd305c4e 100644 --- a/tapir/shifts/models.py +++ b/tapir/shifts/models.py @@ -1206,12 +1206,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" - ) def get_staffingstatus_choices(): @@ -1222,6 +1216,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 @@ -1253,6 +1251,15 @@ class ShiftWatch(models.Model): on_delete=models.CASCADE, ) + watched_capabilities = ArrayField( + models.CharField( + max_length=128, choices=get_shift_capability_choices, 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() 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 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 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) 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"