Skip to content

Commit

Permalink
#582 add a reminder mail for flying members (#583)
Browse files Browse the repository at this point in the history
* Added flying members registration reminder mail.

* Added translation to flying members reminder mail. Added feature flag.

* Translation file update after merge from master.

* Updated description of the mail.

* Only consider some attendance states in is_member_registered_to_a_shift_this_cycle

* Fixed test name
  • Loading branch information
Theophile-Madet authored Nov 15, 2024
1 parent 0070075 commit 86d9650
Show file tree
Hide file tree
Showing 12 changed files with 613 additions and 4 deletions.
10 changes: 8 additions & 2 deletions tapir/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tapir/shifts/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@ 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)
TapirEmailBase.register_email(StandInFoundEmail)
TapirEmailBase.register_email(MemberFrozenEmail)
TapirEmailBase.register_email(FreezeWarningEmail)
TapirEmailBase.register_email(UnfreezeNotificationEmail)
TapirEmailBase.register_email(FlyingMemberRegistrationReminderEmail)
4 changes: 4 additions & 0 deletions tapir/shifts/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
51 changes: 51 additions & 0 deletions tapir/shifts/emails/flying_member_registration_reminder_email.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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()
30 changes: 30 additions & 0 deletions tapir/shifts/services/shift_cycle_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
]
)
5 changes: 5 additions & 0 deletions tapir/shifts/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
@@ -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 %}
<p>Hi {{ display_name_short }},</p>

<p>
we would like to remind you about your next <a href="https://members.supercoop.de/shifts/calendar">shift
registration</a>.
</p>
<p>
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.
</p>
<p>
So take a look at the <a href="https://members.supercoop.de/shifts/calendar">shift calendar on Tapir</a>
and register for one of the shifts highlighted in blue - this is where the most support is currently needed.
</p>
<p>
Cooperative greetings,<br />
The Member Office
</p>
{% endblocktranslate %}
{% endblock body %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% load i18n %}
{% translate "Sign up for your next SuperCoop shift" %}
34 changes: 33 additions & 1 deletion tapir/shifts/tests/test_ShiftCycleService.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Loading

0 comments on commit 86d9650

Please sign in to comment.