diff --git a/settings.py b/settings.py index d2fcd70f..9b92257d 100644 --- a/settings.py +++ b/settings.py @@ -37,6 +37,9 @@ "Email change notification": 1551600, "Verify changed user email": 1551594, "Account deletion": 1567477, + "Inactive users on website - first reminder": 1604381, + "Inactive users on website - second reminder": 1606208, + "Inactive users on website - final reminder": 1606215, } # Build paths inside the project like this: BASE_DIR / 'subdir'. diff --git a/src/api/views/user.py b/src/api/views/user.py index d636a19d..751f6fbe 100644 --- a/src/api/views/user.py +++ b/src/api/views/user.py @@ -19,7 +19,7 @@ from codeforlife.user.views import UserViewSet as _UserViewSet from codeforlife.views import action, cron_job from django.conf import settings -from django.db.models import F +from django.db.models import F, Q from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -64,6 +64,9 @@ def get_permissions(self): "send_1st_verify_email_reminder", "send_2nd_verify_email_reminder", "anonymize_unverified_accounts", + "send_1st_inactivity_reminder", + "send_2nd_inactivity_reminder", + "send_final_inactivity_reminder", ]: return [IsCronRequestFromGoogle()] @@ -340,3 +343,85 @@ def anonymize_unverified_accounts(self, request: Request): ) return Response() + + def _get_inactive_users(self, days: int): + now = timezone.now() + + # All users who haven't logged in in X days OR who've never logged in + # and registered over X days ago. + user_queryset = User.objects.filter( + Q( + last_login__isnull=False, + last_login__lte=now - timedelta(days=days), + last_login__gt=now - timedelta(days=days + 1), + ) + | Q( + last_login__isnull=True, + date_joined__lte=now - timedelta(days=days), + date_joined__gt=now - timedelta(days=days + 1), + ) + ) + + return user_queryset.exclude(email__isnull=True).exclude(email="") + + def _send_inactivity_reminder(self, days: int, campaign_name: str): + user_queryset = self._get_inactive_users(days) + user_count = user_queryset.count() + + logging.info("%d inactive users after %d days.", user_count, days) + + if user_count > 0: + sent_email_count = 0 + for email in user_queryset.values_list("email", flat=True).iterator( + chunk_size=500 + ): + try: + send_mail( + campaign_id=settings.DOTDIGITAL_CAMPAIGN_IDS[ + campaign_name + ], + to_addresses=[email], + ) + + sent_email_count += 1 + # pylint: disable-next=broad-exception-caught + except Exception as ex: + logging.exception(ex) + + logging.info( + "Reminded %d/%d inactive users.", sent_email_count, user_count + ) + + return Response() + + @cron_job + def send_1st_inactivity_reminder(self, request: Request): + """ + Send the first reminder email to teachers and independent users who + haven't been active in a while. + """ + return self._send_inactivity_reminder( + days=730, campaign_name="Inactive users on website - first reminder" + ) + + @cron_job + def send_2nd_inactivity_reminder(self, request: Request): + """ + Send the second reminder email to teachers and independent users who + haven't been active in a while. + """ + return self._send_inactivity_reminder( + days=973, + campaign_name="Inactive users on website - second reminder", + ) + + @cron_job + def send_final_inactivity_reminder(self, request: Request): + """ + Send the final reminder email to teachers and independent users who + haven't been active in a while. + """ + return self._send_inactivity_reminder( + days=1065, + campaign_name="Inactive users on website - final reminder", + ) diff --git a/src/api/views/user_test.py b/src/api/views/user_test.py index edd1aa61..6fb788cd 100644 --- a/src/api/views/user_test.py +++ b/src/api/views/user_test.py @@ -160,6 +160,27 @@ def test_get_permissions__anonymize_unverified_accounts(self): action="anonymize_unverified_accounts", ) + def test_get_permissions__send_1st_inactivity_reminder(self): + """Only Google can send the 1st inactivity reminder.""" + self.assert_get_permissions( + permissions=[IsCronRequestFromGoogle()], + action="send_1st_inactivity_reminder", + ) + + def test_get_permissions__send_2nd_inactivity_reminder(self): + """Only Google can send the 2nd inactivity reminder.""" + self.assert_get_permissions( + permissions=[IsCronRequestFromGoogle()], + action="send_2nd_inactivity_reminder", + ) + + def test_get_permissions__send_final_inactivity_reminder(self): + """Only Google can send the final inactivity reminder.""" + self.assert_get_permissions( + permissions=[IsCronRequestFromGoogle()], + action="send_final_inactivity_reminder", + ) + def test_get_permissions__register_to_newsletter(self): """Any one can register to our newsletter.""" self.assert_get_permissions( @@ -723,6 +744,72 @@ def anonymize_unverified_users( is_anonymized=True, ) + def _test_send_inactivity_reminder( + self, action: str, days: int, campaign_name: str + ): + def test_send_inactivity_reminder(days: int, mail_sent: bool): + date_joined = timezone.now() - timedelta(days, hours=12) + last_login = timezone.now() - timedelta(days, hours=12) + + assert StudentUser.objects.update(date_joined=date_joined) + + TeacherUser.objects.update(date_joined=date_joined, last_login=None) + IndependentUser.objects.update(last_login=last_login) + + teacher_users = list(TeacherUser.objects.all()) + assert teacher_users + indy_users = list(IndependentUser.objects.all()) + assert indy_users + + with patch("src.api.views.user.send_mail") as send_mail_mock: + self.client.cron_job(action) + + if mail_sent: + send_mail_mock.assert_has_calls( + [ + call( + campaign_id=( + settings.DOTDIGITAL_CAMPAIGN_IDS[ + campaign_name + ] + ), + to_addresses=[user.email], + ) + for user in teacher_users + indy_users + ], + any_order=True, + ) + else: + send_mail_mock.assert_not_called() + + test_send_inactivity_reminder(days=days - 1, mail_sent=False) + test_send_inactivity_reminder(days=days, mail_sent=True) + test_send_inactivity_reminder(days=days + 1, mail_sent=False) + + def test_send_1st_inactivity_reminder(self): + """Can send the 1st inactivity reminder.""" + self._test_send_inactivity_reminder( + action="send_1st_inactivity_reminder", + days=730, + campaign_name="Inactive users on website - first reminder", + ) + + def test_send_2nd_inactivity_reminder(self): + """Can send the 2nd inactivity reminder.""" + self._test_send_inactivity_reminder( + action="send_2nd_inactivity_reminder", + days=973, + campaign_name="Inactive users on website - second reminder", + ) + + def test_send_final_inactivity_reminder(self): + """Can send the final inactivity reminder.""" + self._test_send_inactivity_reminder( + action="send_final_inactivity_reminder", + days=1065, + campaign_name="Inactive users on website - final reminder", + ) + # test: other actions def test_register_to_newsletter(self):