diff --git a/tapir/settings.py b/tapir/settings.py
index 3051ee320..5a37014d0 100644
--- a/tapir/settings.py
+++ b/tapir/settings.py
@@ -131,11 +131,13 @@
},
"apply_shift_cycle_start": {
"task": "tapir.shifts.tasks.apply_shift_cycle_start",
- "schedule": celery.schedules.crontab(hour="*/2", minute=20),
+ "schedule": celery.schedules.crontab(hour="*/2", minute="20"),
},
"send_accounting_recap": {
"task": "tapir.coop.tasks.send_accounting_recap",
- "schedule": celery.schedules.crontab(hour=12, minute=0, day_of_week="sunday"),
+ "schedule": celery.schedules.crontab(
+ hour="12", minute="0", day_of_week="sunday"
+ ),
},
"generate_shifts": {
"task": "tapir.shifts.tasks.generate_shifts",
@@ -161,6 +163,10 @@
"task": "tapir.core.tasks.metabase_export",
"schedule": celery.schedules.crontab(minute=0, hour=3),
},
+ "send_flying_member_registration_reminder_mails": {
+ "task": "tapir.shifts.tasks.send_flying_member_registration_reminder_mails",
+ "schedule": celery.schedules.crontab(minute=0, hour=4),
+ },
}
# Password validation
diff --git a/tapir/shifts/apps.py b/tapir/shifts/apps.py
index 6562bdcc9..c9d3424e7 100644
--- a/tapir/shifts/apps.py
+++ b/tapir/shifts/apps.py
@@ -107,6 +107,9 @@ def register_emails():
from tapir.shifts.emails.unfreeze_notification_email import (
UnfreezeNotificationEmail,
)
+ from tapir.shifts.emails.flying_member_registration_reminder_email import (
+ FlyingMemberRegistrationReminderEmail,
+ )
TapirEmailBase.register_email(ShiftMissedEmail)
TapirEmailBase.register_email(ShiftReminderEmail)
@@ -114,3 +117,4 @@ def register_emails():
TapirEmailBase.register_email(MemberFrozenEmail)
TapirEmailBase.register_email(FreezeWarningEmail)
TapirEmailBase.register_email(UnfreezeNotificationEmail)
+ TapirEmailBase.register_email(FlyingMemberRegistrationReminderEmail)
diff --git a/tapir/shifts/config.py b/tapir/shifts/config.py
index 0b874e86f..02afd422b 100644
--- a/tapir/shifts/config.py
+++ b/tapir/shifts/config.py
@@ -6,9 +6,13 @@
cycle_start_dates.sort()
REMINDER_EMAIL_DAYS_BEFORE_SHIFT = 9
+FLYING_MEMBERS_REGISTRATION_REMINDER_DAYS_AFTER_CYCLE_START = 7
FREEZE_THRESHOLD = -4
FREEZE_AFTER_DAYS = 10
NB_WEEKS_IN_THE_FUTURE_FOR_MAKE_UP_SHIFTS = 8
FEATURE_FLAG_SHIFT_PARTNER = "feature_flags.shifts.shift_partner"
+FEATURE_FLAG_FLYING_MEMBERS_REGISTRATION_REMINDER = (
+ "feature_flags.shifts.flying_members_registration_reminder"
+)
diff --git a/tapir/shifts/emails/flying_member_registration_reminder_email.py b/tapir/shifts/emails/flying_member_registration_reminder_email.py
new file mode 100644
index 000000000..505d0f838
--- /dev/null
+++ b/tapir/shifts/emails/flying_member_registration_reminder_email.py
@@ -0,0 +1,51 @@
+from typing import List
+
+from django.utils.translation import gettext_lazy as _
+
+from tapir.coop.models import ShareOwner
+from tapir.core.tapir_email_base import TapirEmailBase
+from tapir.shifts import config
+
+
+class FlyingMemberRegistrationReminderEmail(TapirEmailBase):
+ @classmethod
+ def get_unique_id(cls) -> str:
+ return "tapir.shifts.flying_member_registration_reminder_email"
+
+ @classmethod
+ def get_name(cls) -> str:
+ return _("Flying member registration reminder")
+
+ @classmethod
+ def get_description(cls) -> str:
+ return _(
+ "Sent to flying members %(nb_days)s days after a cycle has begun, if they haven't registered to a shift for this cycle."
+ % {
+ "nb_days": config.FLYING_MEMBERS_REGISTRATION_REMINDER_DAYS_AFTER_CYCLE_START
+ }
+ )
+
+ def get_subject_templates(self) -> List:
+ return [
+ "shifts/email/flying_member_registration_reminder_email.subject.html",
+ ]
+
+ def get_body_templates(self) -> List:
+ return [
+ "shifts/email/flying_member_registration_reminder_email.body.html",
+ ]
+
+ @classmethod
+ def get_dummy_version(cls) -> TapirEmailBase | None:
+ share_owner = (
+ ShareOwner.objects.filter(user__isnull=False).order_by("?").first()
+ )
+ if not share_owner:
+ return None
+ mail = cls()
+ mail.get_full_context(
+ share_owner=share_owner,
+ member_infos=share_owner.get_info(),
+ tapir_user=share_owner.user,
+ )
+ return mail
diff --git a/tapir/shifts/management/commands/send_flying_member_registration_reminder_mails.py b/tapir/shifts/management/commands/send_flying_member_registration_reminder_mails.py
new file mode 100644
index 000000000..38c919a2e
--- /dev/null
+++ b/tapir/shifts/management/commands/send_flying_member_registration_reminder_mails.py
@@ -0,0 +1,85 @@
+import datetime
+
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from tapir.core.models import FeatureFlag
+from tapir.log.models import EmailLogEntry
+from tapir.shifts.config import FEATURE_FLAG_FLYING_MEMBERS_REGISTRATION_REMINDER
+from tapir.shifts.emails.flying_member_registration_reminder_email import (
+ FlyingMemberRegistrationReminderEmail,
+)
+from tapir.shifts.models import (
+ ShiftUserData,
+ ShiftAttendanceMode,
+ ShiftCycleEntry,
+ ShiftAttendance,
+)
+from tapir.shifts.services.shift_cycle_service import ShiftCycleService
+from tapir.shifts.services.shift_expectation_service import ShiftExpectationService
+
+
+class Command(BaseCommand):
+ help = "Send FlyingMemberRegistrationReminderEmail if necessary."
+
+ def handle(self, *args, **options):
+ if not FeatureFlag.get_flag_value(
+ FEATURE_FLAG_FLYING_MEMBERS_REGISTRATION_REMINDER
+ ):
+ return
+
+ start_date = ShiftCycleService.get_start_date_of_current_cycle()
+ end_date = start_date + datetime.timedelta(
+ days=ShiftCycleEntry.SHIFT_CYCLE_DURATION
+ )
+ if timezone.now().date() > end_date - datetime.timedelta(days=7):
+ # Don't send mails if the cycle is about to end
+ return
+
+ flying_members = ShiftUserData.objects.filter(
+ attendance_mode=ShiftAttendanceMode.FLYING
+ )
+ for shift_user_data in flying_members:
+ if not self.should_member_receive_reminder_mail(
+ shift_user_data, start_date
+ ):
+ continue
+ FlyingMemberRegistrationReminderEmail().send_to_tapir_user(
+ actor=None, recipient=shift_user_data.user
+ )
+
+ @classmethod
+ def should_member_receive_reminder_mail(cls, shift_user_data, start_date):
+ if not ShiftExpectationService.is_member_expected_to_do_shifts(shift_user_data):
+ return False
+ if cls.has_user_received_reminder_this_cycle(shift_user_data, start_date):
+ return False
+ if cls.is_member_registered_to_a_shift_this_cycle(shift_user_data, start_date):
+ return False
+ return True
+
+ @staticmethod
+ def has_user_received_reminder_this_cycle(
+ shift_user_data: ShiftUserData, cycle_start_date: datetime.date
+ ):
+ cycle_end_date = cycle_start_date + datetime.timedelta(
+ days=ShiftCycleEntry.SHIFT_CYCLE_DURATION
+ )
+ return EmailLogEntry.objects.filter(
+ email_id=FlyingMemberRegistrationReminderEmail.get_unique_id(),
+ user=shift_user_data.user,
+ created_date__gte=cycle_start_date,
+ created_date__lte=cycle_end_date,
+ ).exists()
+
+ @staticmethod
+ def is_member_registered_to_a_shift_this_cycle(
+ shift_user_data: ShiftUserData, cycle_start_date: datetime.date
+ ):
+ return ShiftAttendance.objects.filter(
+ user=shift_user_data.user,
+ slot__shift__start_time__date__gte=cycle_start_date,
+ slot__shift__start_time__date__lt=cycle_start_date
+ + datetime.timedelta(days=ShiftCycleEntry.SHIFT_CYCLE_DURATION),
+ state__in=ShiftAttendance.VALID_STATES,
+ ).exists()
diff --git a/tapir/shifts/services/shift_cycle_service.py b/tapir/shifts/services/shift_cycle_service.py
index 788169907..7c38eead8 100644
--- a/tapir/shifts/services/shift_cycle_service.py
+++ b/tapir/shifts/services/shift_cycle_service.py
@@ -4,6 +4,7 @@
from django.db.models import Max
from django.utils import timezone
+from tapir.shifts.config import cycle_start_dates
from tapir.shifts.models import (
ShiftUserData,
ShiftCycleEntry,
@@ -92,3 +93,32 @@ def apply_cycles_from(start: datetime.date, end: datetime.date | None = None):
new_cycle_start_date += datetime.timedelta(
days=ShiftCycleEntry.SHIFT_CYCLE_DURATION
)
+
+ @classmethod
+ def get_start_date_of_current_cycle(cls, today: datetime.date | None = None):
+ if today is None:
+ today = timezone.now().date()
+
+ cycle_start_date = cls.get_reference_date_for_cycles(today)
+ # get the date for the coming cycle
+ while cycle_start_date < today:
+ cycle_start_date += datetime.timedelta(
+ days=ShiftCycleEntry.SHIFT_CYCLE_DURATION
+ )
+ # go back one cycle to get the current cycle
+ return cycle_start_date - datetime.timedelta(
+ days=ShiftCycleEntry.SHIFT_CYCLE_DURATION
+ )
+
+ @staticmethod
+ def get_reference_date_for_cycles(today: datetime.date | None = None):
+ if today is None:
+ today = timezone.now().date()
+
+ return max(
+ [
+ cycle_start_date
+ for cycle_start_date in cycle_start_dates
+ if cycle_start_date <= today
+ ]
+ )
diff --git a/tapir/shifts/tasks.py b/tapir/shifts/tasks.py
index 4dbce86b5..652a09a52 100644
--- a/tapir/shifts/tasks.py
+++ b/tapir/shifts/tasks.py
@@ -20,3 +20,8 @@ def generate_shifts():
@shared_task
def run_freeze_checks():
call_command("run_freeze_checks")
+
+
+@shared_task
+def send_flying_member_registration_reminder_mails():
+ call_command("send_flying_member_registration_reminder_mails")
diff --git a/tapir/shifts/templates/shifts/email/flying_member_registration_reminder_email.body.html b/tapir/shifts/templates/shifts/email/flying_member_registration_reminder_email.body.html
new file mode 100644
index 000000000..153d8df07
--- /dev/null
+++ b/tapir/shifts/templates/shifts/email/flying_member_registration_reminder_email.body.html
@@ -0,0 +1,27 @@
+{% extends "core/email_base.html" %}
+{% load i18n %}
+{% load utils %}
+{% block body %}
+ {% get_display_name_short member_infos as display_name_short %}
+ {% blocktranslate with display_name_short=display_name_short %}
+
Hi {{ display_name_short }},
+
+
+ we would like to remind you about your next shift
+ registration.
+
+
+ A new shift cycle has been running for a week and we have noticed
+ that you have not yet registered for a suitable shift. As a flying member,
+ unlike members with a regular ABCD shift, you have to take care of this yourself every four weeks.
+
+
+ So take a look at the shift calendar on Tapir
+ and register for one of the shifts highlighted in blue - this is where the most support is currently needed.
+
+
+ Cooperative greetings,
+ The Member Office
+
+ {% endblocktranslate %}
+{% endblock body %}
diff --git a/tapir/shifts/templates/shifts/email/flying_member_registration_reminder_email.subject.html b/tapir/shifts/templates/shifts/email/flying_member_registration_reminder_email.subject.html
new file mode 100644
index 000000000..d3004253e
--- /dev/null
+++ b/tapir/shifts/templates/shifts/email/flying_member_registration_reminder_email.subject.html
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% translate "Sign up for your next SuperCoop shift" %}
diff --git a/tapir/shifts/tests/test_ShiftCycleService.py b/tapir/shifts/tests/test_ShiftCycleService.py
index 2ae9477c0..c71605639 100644
--- a/tapir/shifts/tests/test_ShiftCycleService.py
+++ b/tapir/shifts/tests/test_ShiftCycleService.py
@@ -14,7 +14,7 @@
)
from tapir.shifts.services.shift_cycle_service import ShiftCycleService
from tapir.shifts.tests.factories import ShiftTemplateFactory
-from tapir.utils.tests_utils import TapirFactoryTestBase
+from tapir.utils.tests_utils import TapirFactoryTestBase, mock_timezone_now
class TestShiftCycleService(TapirFactoryTestBase):
@@ -198,3 +198,35 @@ def test_cycleStartCommand_noPastShiftNorCycleEntry_doesntCallApplyCycles(
):
call_command("apply_shift_cycle_start")
mock_apply_cycles_from.assert_not_called()
+
+ @patch.object(ShiftCycleService, "get_reference_date_for_cycles")
+ def test_getStartDateOfCurrentCycle_default_returnsCorrectDate(
+ self, mock_get_reference_date_for_cycles: Mock
+ ):
+ mock_timezone_now(self, datetime.datetime(year=2024, month=6, day=15))
+
+ mock_get_reference_date_for_cycles.return_value = datetime.date(
+ year=2024, month=5, day=6
+ )
+ result = ShiftCycleService.get_start_date_of_current_cycle()
+
+ self.assertEqual(datetime.date(year=2024, month=6, day=3), result)
+
+ def test_getReferenceDateForCycle_default_returnsCorrectDate(self):
+ mock_timezone_now(self, datetime.datetime(year=2024, month=6, day=15))
+ date_not_valid_anymore = datetime.date(year=2024, month=1, day=15)
+ current_valid_date = datetime.date(year=2024, month=3, day=15)
+ date_not_valid_yet = datetime.date(year=2024, month=9, day=15)
+
+ cycle_start_dates = [
+ date_not_valid_anymore,
+ current_valid_date,
+ date_not_valid_yet,
+ ]
+ with mock.patch(
+ "tapir.shifts.services.shift_cycle_service.cycle_start_dates",
+ cycle_start_dates,
+ ):
+ result = ShiftCycleService.get_reference_date_for_cycles()
+
+ self.assertEqual(current_valid_date, result)
diff --git a/tapir/shifts/tests/test_flying_members_registration_reminder_mail.py b/tapir/shifts/tests/test_flying_members_registration_reminder_mail.py
new file mode 100644
index 000000000..e0ebc10c0
--- /dev/null
+++ b/tapir/shifts/tests/test_flying_members_registration_reminder_mail.py
@@ -0,0 +1,309 @@
+import datetime
+from unittest.mock import patch, Mock
+
+from django.core import mail
+from django.core.management import call_command
+
+from tapir.accounts.tests.factories.factories import TapirUserFactory
+from tapir.log.models import EmailLogEntry
+from tapir.shifts.config import FEATURE_FLAG_FLYING_MEMBERS_REGISTRATION_REMINDER
+from tapir.shifts.emails.flying_member_registration_reminder_email import (
+ FlyingMemberRegistrationReminderEmail,
+)
+from tapir.shifts.management.commands.send_flying_member_registration_reminder_mails import (
+ Command,
+)
+from tapir.shifts.models import ShiftAttendanceMode, ShiftUserData, ShiftAttendance
+from tapir.shifts.services.shift_cycle_service import ShiftCycleService
+from tapir.shifts.services.shift_expectation_service import ShiftExpectationService
+from tapir.shifts.tests.factories import ShiftFactory
+from tapir.utils.tests_utils import (
+ TapirFactoryTestBase,
+ TapirEmailTestBase,
+ mock_timezone_now,
+ FeatureFlagTestMixin,
+)
+
+
+class TestAttendanceUpdateMemberOffice(
+ FeatureFlagTestMixin, TapirFactoryTestBase, TapirEmailTestBase
+):
+ NOW = datetime.datetime(year=2024, month=6, day=15)
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.NOW = mock_timezone_now(self, self.NOW)
+ self.given_feature_flag_value(
+ FEATURE_FLAG_FLYING_MEMBERS_REGISTRATION_REMINDER, True
+ )
+
+ @patch.object(ShiftCycleService, "get_start_date_of_current_cycle")
+ def test_sendFlyingMemberRegistrationReminderMailsCommand_featureFlagDisabled_noMailSent(
+ self,
+ mock_get_start_date_of_current_cycle: Mock,
+ ):
+ self.given_feature_flag_value(
+ FEATURE_FLAG_FLYING_MEMBERS_REGISTRATION_REMINDER, False
+ )
+
+ call_command("send_flying_member_registration_reminder_mails")
+
+ mock_get_start_date_of_current_cycle.filter.assert_not_called()
+ self.assertEqual(0, len(mail.outbox))
+
+ @patch.object(ShiftCycleService, "get_start_date_of_current_cycle")
+ @patch.object(ShiftUserData, "objects")
+ def test_sendFlyingMemberRegistrationReminderMailsCommand_todayIsCloseToEndOfCycle_noMailSent(
+ self,
+ mock_objects: Mock,
+ mock_get_start_date_of_current_cycle: Mock,
+ ):
+ mock_get_start_date_of_current_cycle.return_value = (
+ self.NOW - datetime.timedelta(days=25)
+ ).date()
+
+ call_command("send_flying_member_registration_reminder_mails")
+
+ mock_objects.filter.assert_not_called()
+ self.assertEqual(0, len(mail.outbox))
+
+ @patch.object(ShiftCycleService, "get_start_date_of_current_cycle")
+ @patch.object(Command, "should_member_receive_reminder_mail")
+ def test_sendFlyingMemberRegistrationReminderMailsCommand_userNotFlying_noMailSent(
+ self,
+ mock_should_member_receive_reminder_mail: Mock,
+ mock_get_start_date_of_current_cycle: Mock,
+ ):
+ mock_get_start_date_of_current_cycle.return_value = (
+ self.NOW - datetime.timedelta(days=7)
+ ).date()
+
+ tapir_user = TapirUserFactory.create()
+ tapir_user.shift_user_data.attendance_mode = ShiftAttendanceMode.REGULAR
+ tapir_user.shift_user_data.save()
+
+ call_command("send_flying_member_registration_reminder_mails")
+
+ mock_should_member_receive_reminder_mail.assert_not_called()
+ self.assertEqual(0, len(mail.outbox))
+
+ @patch.object(ShiftCycleService, "get_start_date_of_current_cycle")
+ @patch.object(Command, "should_member_receive_reminder_mail")
+ def test_sendFlyingMemberRegistrationReminderMailsCommand_userShouldNotReceiveMail_noMailSent(
+ self,
+ mock_should_member_receive_reminder_mail: Mock,
+ mock_get_start_date_of_current_cycle: Mock,
+ ):
+ cycle_start_date = (self.NOW - datetime.timedelta(days=7)).date()
+ mock_get_start_date_of_current_cycle.return_value = cycle_start_date
+ tapir_user = TapirUserFactory.create()
+ tapir_user.shift_user_data.attendance_mode = ShiftAttendanceMode.FLYING
+ tapir_user.shift_user_data.save()
+ mock_should_member_receive_reminder_mail.return_value = False
+
+ call_command("send_flying_member_registration_reminder_mails")
+
+ mock_should_member_receive_reminder_mail.assert_called_once_with(
+ tapir_user.shift_user_data, cycle_start_date
+ )
+ self.assertEqual(0, len(mail.outbox))
+
+ @patch.object(ShiftCycleService, "get_start_date_of_current_cycle")
+ @patch.object(Command, "should_member_receive_reminder_mail")
+ def test_sendFlyingMemberRegistrationReminderMailsCommand_userShouldReceiveMail_mailSent(
+ self,
+ mock_should_member_receive_reminder_mail: Mock,
+ mock_get_start_date_of_current_cycle: Mock,
+ ):
+ cycle_start_date = (self.NOW - datetime.timedelta(days=7)).date()
+ mock_get_start_date_of_current_cycle.return_value = cycle_start_date
+ tapir_user = TapirUserFactory.create()
+ tapir_user.shift_user_data.attendance_mode = ShiftAttendanceMode.FLYING
+ tapir_user.shift_user_data.save()
+ mock_should_member_receive_reminder_mail.return_value = True
+
+ call_command("send_flying_member_registration_reminder_mails")
+
+ mock_should_member_receive_reminder_mail.assert_called_once_with(
+ tapir_user.shift_user_data, cycle_start_date
+ )
+ self.assertEqual(1, len(mail.outbox))
+ self.assertEmailOfClass_GotSentTo(
+ FlyingMemberRegistrationReminderEmail, tapir_user.email, mail.outbox[0]
+ )
+
+ @patch.object(ShiftExpectationService, "is_member_expected_to_do_shifts")
+ def test_shouldMemberReceiveReminderMail_memberNotExpectedToDoShifts_returnsFalse(
+ self, mock_is_member_expected_to_do_shifts: Mock
+ ):
+ shift_user_data = Mock()
+ mock_is_member_expected_to_do_shifts.return_value = False
+
+ result = Command.should_member_receive_reminder_mail(
+ shift_user_data, self.NOW.date()
+ )
+
+ self.assertFalse(result)
+ mock_is_member_expected_to_do_shifts.assert_called_once_with(shift_user_data)
+
+ @patch.object(ShiftExpectationService, "is_member_expected_to_do_shifts")
+ @patch.object(Command, "has_user_received_reminder_this_cycle")
+ def test_shouldMemberReceiveReminderMail_memberAlreadyReceivedMailThisCycle_returnsFalse(
+ self,
+ mock_has_user_received_reminder_this_cycle: Mock,
+ mock_is_member_expected_to_do_shifts: Mock,
+ ):
+ shift_user_data = Mock()
+ mock_is_member_expected_to_do_shifts.return_value = True
+ mock_has_user_received_reminder_this_cycle.return_value = True
+ start_date = Mock()
+
+ result = Command.should_member_receive_reminder_mail(
+ shift_user_data, start_date
+ )
+
+ self.assertFalse(result)
+ mock_is_member_expected_to_do_shifts.assert_called_once_with(shift_user_data)
+ mock_has_user_received_reminder_this_cycle.assert_called_once_with(
+ shift_user_data, start_date
+ )
+
+ @patch.object(ShiftExpectationService, "is_member_expected_to_do_shifts")
+ @patch.object(Command, "has_user_received_reminder_this_cycle")
+ @patch.object(Command, "is_member_registered_to_a_shift_this_cycle")
+ def test_shouldMemberReceiveReminderMail_memberAlreadyRegisteredToAShiftThisCycle_returnsFalse(
+ self,
+ mock_is_member_registered_to_a_shift_this_cycle: Mock,
+ mock_has_user_received_reminder_this_cycle: Mock,
+ mock_is_member_expected_to_do_shifts: Mock,
+ ):
+ shift_user_data = Mock()
+ mock_is_member_expected_to_do_shifts.return_value = True
+ mock_has_user_received_reminder_this_cycle.return_value = False
+ mock_is_member_registered_to_a_shift_this_cycle.return_value = True
+ start_date = Mock()
+
+ result = Command.should_member_receive_reminder_mail(
+ shift_user_data, start_date
+ )
+
+ self.assertFalse(result)
+ mock_is_member_expected_to_do_shifts.assert_called_once_with(shift_user_data)
+ mock_has_user_received_reminder_this_cycle.assert_called_once_with(
+ shift_user_data, start_date
+ )
+ mock_is_member_registered_to_a_shift_this_cycle.assert_called_once_with(
+ shift_user_data, start_date
+ )
+
+ @patch.object(ShiftExpectationService, "is_member_expected_to_do_shifts")
+ @patch.object(Command, "has_user_received_reminder_this_cycle")
+ @patch.object(Command, "is_member_registered_to_a_shift_this_cycle")
+ def test_shouldMemberReceiveReminderMail_noReasonNotToSend_returnsTrue(
+ self,
+ mock_is_member_registered_to_a_shift_this_cycle: Mock,
+ mock_has_user_received_reminder_this_cycle: Mock,
+ mock_is_member_expected_to_do_shifts: Mock,
+ ):
+ shift_user_data = Mock()
+ mock_is_member_expected_to_do_shifts.return_value = True
+ mock_has_user_received_reminder_this_cycle.return_value = False
+ mock_is_member_registered_to_a_shift_this_cycle.return_value = False
+ start_date = Mock()
+
+ result = Command.should_member_receive_reminder_mail(
+ shift_user_data, start_date
+ )
+
+ self.assertTrue(result)
+ mock_is_member_expected_to_do_shifts.assert_called_once_with(shift_user_data)
+ mock_has_user_received_reminder_this_cycle.assert_called_once_with(
+ shift_user_data, start_date
+ )
+ mock_is_member_registered_to_a_shift_this_cycle.assert_called_once_with(
+ shift_user_data, start_date
+ )
+
+ def test_hasUserReceivedReminderThisCycle_noLogEntryForThisCycle_returnsFalse(self):
+ tapir_user = TapirUserFactory.create()
+ cycle_start_date = self.NOW.date()
+ date_before_the_cycle = cycle_start_date - datetime.timedelta(days=1)
+ date_after_the_cycle = cycle_start_date + datetime.timedelta(days=30)
+ for date in [date_before_the_cycle, date_after_the_cycle]:
+ entry = EmailLogEntry.objects.create(
+ email_id=FlyingMemberRegistrationReminderEmail.get_unique_id(),
+ user=tapir_user,
+ )
+ entry.created_date = date
+ entry.save()
+
+ self.assertFalse(
+ Command.has_user_received_reminder_this_cycle(
+ tapir_user.shift_user_data, cycle_start_date
+ )
+ )
+
+ def test_hasUserReceivedReminderThisCycle_logEntryExistsWithinCycle_returnsTrue(
+ self,
+ ):
+ tapir_user = TapirUserFactory.create()
+ cycle_start_date = self.NOW.date()
+
+ entry = EmailLogEntry.objects.create(
+ email_id=FlyingMemberRegistrationReminderEmail.get_unique_id(),
+ user=tapir_user,
+ )
+ entry.created_date = cycle_start_date + datetime.timedelta(days=1)
+ entry.save()
+
+ self.assertTrue(
+ Command.has_user_received_reminder_this_cycle(
+ tapir_user.shift_user_data, cycle_start_date
+ )
+ )
+
+ def test_isMemberRegisteredToAShiftThisCycle_noAttendanceExistsWithinCycle_returnsFalse(
+ self,
+ ):
+ tapir_user = TapirUserFactory.create()
+ cycle_start_date = self.NOW.date()
+ date_before_the_cycle = cycle_start_date - datetime.timedelta(days=1)
+ date_after_the_cycle = cycle_start_date + datetime.timedelta(days=30)
+ for date in [date_before_the_cycle, date_after_the_cycle]:
+ shift = ShiftFactory.create(start_time=date)
+ ShiftAttendance.objects.create(
+ user=tapir_user,
+ slot=shift.slots.first(),
+ state=ShiftAttendance.State.PENDING,
+ )
+
+ shift = ShiftFactory.create(
+ start_time=cycle_start_date + datetime.timedelta(days=2)
+ )
+ ShiftAttendance.objects.create(
+ user=tapir_user,
+ slot=shift.slots.first(),
+ state=ShiftAttendance.State.CANCELLED,
+ )
+
+ self.assertFalse(
+ Command.is_member_registered_to_a_shift_this_cycle(
+ tapir_user.shift_user_data, cycle_start_date
+ )
+ )
+
+ def test_isMemberRegisteredToAShiftThisCycle_attendanceExistsWithinCycle_returnsTrue(
+ self,
+ ):
+ tapir_user = TapirUserFactory.create()
+ cycle_start_date = self.NOW.date()
+ shift = ShiftFactory.create(
+ start_time=cycle_start_date + datetime.timedelta(days=1)
+ )
+ ShiftAttendance.objects.create(user=tapir_user, slot=shift.slots.first())
+
+ self.assertTrue(
+ Command.is_member_registered_to_a_shift_this_cycle(
+ tapir_user.shift_user_data, cycle_start_date
+ )
+ )
diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po
index bdffe7b8c..e9a1a9ae4 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: 2024-11-12 15:56+0100\n"
+"POT-Creation-Date: 2024-11-15 13:07+0100\n"
"PO-Revision-Date: 2024-10-07 11:12+0200\n"
"Last-Translator: \n"
"Language-Team: \n"
@@ -2779,6 +2779,15 @@ msgstr ""
"ABCD-Jahreskalender\n"
"Aktuelle Woche: {current_week_group_name}"
+#: shifts/emails/flying_member_registration_reminder_email.py:17
+msgid "Flying member registration reminder"
+msgstr ""
+
+#: shifts/emails/flying_member_registration_reminder_email.py:22
+#, python-format
+msgid "Sent to flying members %(nb_days)s days after a cycle has begun, if they haven't registered to a shift for this cycle."
+msgstr ""
+
#: shifts/emails/freeze_warning_email.py:28
msgid "Freeze warning"
msgstr ""
@@ -3092,6 +3101,51 @@ msgid ""
" "
msgstr ""
+#: shifts/templates/shifts/email/flying_member_registration_reminder_email.body.html:6
+#, python-format
+msgid ""
+"\n"
+" Hi %(display_name_short)s,
\n"
+"\n"
+" \n"
+" we would like to remind you about your next shift\n"
+" registration.\n"
+"
\n"
+" \n"
+" A new shift cycle has been running for a week and we have noticed\n"
+" that you have not yet registered for a suitable shift. As a flying member,\n"
+" unlike members with a regular ABCD shift, you have to take care of this yourself every four weeks.\n"
+"
\n"
+" \n"
+" So take a look at the shift calendar on Tapir\n"
+" and register for one of the shifts highlighted in blue - this is where the most support is currently needed.\n"
+"
\n"
+" \n"
+" Cooperative greetings,
\n"
+" The Member Office\n"
+"
\n"
+" "
+msgstr ""
+"\n"
+" Hi %(display_name_short)s,
\n"
+"\n"
+" \n"
+" hiermit möchten wir dich an deine nächste Schichtanmeldung zu erinnern..\n"
+"
\n"
+" \n"
+" Seit einer Woche läuft ein neuer Schichtzyklus und wir haben festgestellt, dass du dich bisher noch nicht für eine passende Schicht registriert hast. Als Fliegendes Mitglied musst du dich, im Unterschied zu Mitgliedern mit fester ABCD-Schicht, alle vier Wochen selbst darum kümmern.
\n"
+" \n"
+" Schau also am besten gleich mal in den Schichtkalender auf Tapir, und melde dich gerne für eine der blau unterlegten Schichten an - dort wird aktuell am meisten Unterstützung benötigt.
\n"
+" \n"
+" Kooperative Grüße,
\n"
+" Das Mitgliederbüro\n"
+"
\n"
+" "
+
+#: shifts/templates/shifts/email/flying_member_registration_reminder_email.subject.html:2
+msgid "Sign up for your next SuperCoop shift"
+msgstr "Melde dich für deine nächste SuperCoop-Schicht an"
+
#: shifts/templates/shifts/email/freeze_warning.body.html:6
#, python-format
msgid ""