diff --git a/cfl_common/common/mail.py b/cfl_common/common/mail.py index 007eb7986..948df31a0 100644 --- a/cfl_common/common/mail.py +++ b/cfl_common/common/mail.py @@ -27,6 +27,9 @@ "verify_new_user_second_reminder": 1557173, "verify_new_user_via_parent": 1551587, "verify_released_student": 1580574, + "inactive_users_on_website_first_reminder": 1604381, + "inactive_users_on_website_second_reminder": 1606208, + "inactive_users_on_website_final_reminder": 1606215, } @@ -65,7 +68,11 @@ def django_send_email( plaintext = loader.get_template(plaintext_template) html = loader.get_template(html_template) plaintext_email_context = {"content": text_content} - html_email_context = {"content": text_content, "title": title, "url_prefix": domain()} + html_email_context = { + "content": text_content, + "title": title, + "url_prefix": domain(), + } # render templates plaintext_body = plaintext.render(plaintext_email_context) @@ -74,11 +81,19 @@ def django_send_email( if replace_url: verify_url = replace_url["verify_url"] - verify_replace_url = re.sub(f"(.*/verify_email/)(.*)", f"\\1", verify_url) - html_body = re.sub(f"({verify_url})(.*){verify_url}", f"\\1\\2{verify_replace_url}", original_html_body) + verify_replace_url = re.sub( + f"(.*/verify_email/)(.*)", f"\\1", verify_url + ) + html_body = re.sub( + f"({verify_url})(.*){verify_url}", + f"\\1\\2{verify_replace_url}", + original_html_body, + ) # make message using templates - message = EmailMultiAlternatives(subject, plaintext_body, sender, recipients) + message = EmailMultiAlternatives( + subject, plaintext_body, sender, recipients + ) message.attach_alternative(html_body, "text/html") message.send() @@ -123,7 +138,13 @@ def send_dotdigital_email( # Dotdigital emails don't work locally, so if testing emails locally use Django to send a dummy email instead if MODULE_NAME == "local": - django_send_email(from_address, to_addresses, "dummy_subject", "dummy_text_content", "dummy_title") + django_send_email( + from_address, + to_addresses, + "dummy_subject", + "dummy_text_content", + "dummy_title", + ) else: if auth is None: auth = app_settings.DOTDIGITAL_AUTH @@ -168,4 +189,8 @@ def send_dotdigital_email( timeout=timeout, ) - assert response.ok, "Failed to send email." f" Reason: {response.reason}." f" Text: {response.text}." + assert response.ok, ( + "Failed to send email." + f" Reason: {response.reason}." + f" Text: {response.text}." + ) diff --git a/portal/tests/test_views.py b/portal/tests/test_views.py index dfaaa711b..3837eab8d 100644 --- a/portal/tests/test_views.py +++ b/portal/tests/test_views.py @@ -16,6 +16,7 @@ UserProfile, UserSession, ) +from common.mail import campaign_ids from common.tests.utils.classes import create_class_directly from common.tests.utils.organisation import ( create_organisation_directly, @@ -1197,3 +1198,103 @@ def anonymise_unverified_users( is_verified=False, assert_active=False, ) + + @patch("portal.views.cron.user.send_dotdigital_email") + def send_inactivity_reminder( + self, + days: int, + view_name: str, + assert_called: bool, + campaign_name: str, + mock_send_dotdigital_email: Mock, + ): + self.teacher_user.date_joined = timezone.now() - timedelta( + days=days, hours=12 + ) + self.teacher_user.save() + self.student_user.date_joined = timezone.now() - timedelta( + days=days, hours=12 + ) + self.student_user.save() + self.indy_user.last_login = timezone.now() - timedelta( + days=days, hours=12 + ) + self.indy_user.save() + + self.client.get(reverse(view_name)) + + if assert_called: + mock_send_dotdigital_email.assert_any_call( + campaign_ids[campaign_name], [self.teacher_user.email] + ) + + mock_send_dotdigital_email.assert_any_call( + campaign_ids[campaign_name], [self.indy_user.email] + ) + + # Check only two emails are sent - the student should never be included. + assert mock_send_dotdigital_email.call_count == 2 + else: + mock_send_dotdigital_email.assert_not_called() + + mock_send_dotdigital_email.reset_mock() + + def test_first_inactivity_reminder_view(self): + self.send_inactivity_reminder( + 729, + "first-inactivity-reminder", + False, + "inactive_users_on_website_first_reminder", + ) + self.send_inactivity_reminder( + 730, + "first-inactivity-reminder", + True, + "inactive_users_on_website_first_reminder", + ) + self.send_inactivity_reminder( + 731, + "first-inactivity-reminder", + False, + "inactive_users_on_website_first_reminder", + ) + + def test_second_inactivity_reminder_view(self): + self.send_inactivity_reminder( + 972, + "second-inactivity-reminder", + False, + "inactive_users_on_website_second_reminder", + ) + self.send_inactivity_reminder( + 973, + "second-inactivity-reminder", + True, + "inactive_users_on_website_second_reminder", + ) + self.send_inactivity_reminder( + 974, + "second-inactivity-reminder", + False, + "inactive_users_on_website_second_reminder", + ) + + def test_final_inactivity_reminder_view(self): + self.send_inactivity_reminder( + 1064, + "final-inactivity-reminder", + False, + "inactive_users_on_website_final_reminder", + ) + self.send_inactivity_reminder( + 1065, + "final-inactivity-reminder", + True, + "inactive_users_on_website_final_reminder", + ) + self.send_inactivity_reminder( + 1066, + "final-inactivity-reminder", + False, + "inactive_users_on_website_final_reminder", + ) diff --git a/portal/urls.py b/portal/urls.py index b8f422ca5..a2a11434c 100644 --- a/portal/urls.py +++ b/portal/urls.py @@ -164,6 +164,21 @@ cron.user.AnonymiseUnverifiedAccounts.as_view(), name="anonymise-unverified-accounts", ), + path( + "inactive/send-first-reminder/", + cron.user.FirstInactivityReminderView.as_view(), + name="first-inactivity-reminder", + ), + path( + "inactive/send-second-reminder/", + cron.user.SecondInactivityReminderView.as_view(), + name="second-inactivity-reminder", + ), + path( + "inactive/send-final-reminder/", + cron.user.FinalInactivityReminderView.as_view(), + name="final-inactivity-reminder", + ), ] ), ), diff --git a/portal/views/cron/user.py b/portal/views/cron/user.py index ae676f9dc..488626dd8 100644 --- a/portal/views/cron/user.py +++ b/portal/views/cron/user.py @@ -5,24 +5,28 @@ from common.mail import campaign_ids, send_dotdigital_email from common.models import DailyActivity, TotalActivity from django.contrib.auth.models import User -from django.db.models import F +from django.db.models import F, Q from django.db.models.query import QuerySet from django.urls import reverse from django.utils import timezone from rest_framework.response import Response from rest_framework.views import APIView -from portal.views.api import anonymise - from ...mixins import CronMixin +from ...views.api import anonymise -# TODO: move email templates to DotDigital. USER_1ST_VERIFY_EMAIL_REMINDER_DAYS = 7 USER_2ND_VERIFY_EMAIL_REMINDER_DAYS = 14 USER_DELETE_UNVERIFIED_ACCOUNT_DAYS = 19 +USER_1ST_INACTIVE_REMINDER_DAYS = 730 # 2 years +USER_2ND_INACTIVE_REMINDER_DAYS = 973 # roughly 2 years and 8 months +USER_FINAL_INACTIVE_REMINDER_DAYS = 1065 # 2 years and 11 months + -def get_unverified_users(days: int, same_day: bool) -> (QuerySet[User], QuerySet[User]): +def get_unverified_users( + days: int, same_day: bool +) -> (QuerySet[User], QuerySet[User]): now = timezone.now() # All expired unverified users. @@ -31,7 +35,9 @@ def get_unverified_users(days: int, same_day: bool) -> (QuerySet[User], QuerySet userprofile__is_verified=False, ) if same_day: - user_queryset = user_queryset.filter(date_joined__gt=now - timedelta(days=days + 1)) + user_queryset = user_queryset.filter( + date_joined__gt=now - timedelta(days=days + 1) + ) teacher_queryset = user_queryset.filter( new_teacher__isnull=False, @@ -45,10 +51,31 @@ def get_unverified_users(days: int, same_day: bool) -> (QuerySet[User], QuerySet return teacher_queryset, independent_student_queryset +def get_inactive_users(days: int) -> QuerySet[User]: + 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 build_absolute_google_uri(request, location: str) -> str: """ - This is needed specifically for emails sent by cron jobs as the protocol for cron jobs is HTTP - and the service name is wrongly parsed. + This is needed specifically for emails sent by cron jobs as the protocol for + cron jobs is HTTP and the service name is wrongly parsed. """ url = request.build_absolute_uri(location) url = url.replace("http", "https") @@ -70,7 +97,9 @@ def get(self, request): if user_count > 0: sent_email_count = 0 - for email in user_queryset.values_list("email", flat=True).iterator(chunk_size=500): + for email in user_queryset.values_list("email", flat=True).iterator( + chunk_size=500 + ): email_verification_url = build_absolute_google_uri( request, reverse( @@ -83,7 +112,9 @@ def get(self, request): send_dotdigital_email( campaign_ids["verify_new_user_first_reminder"], [email], - personalization_values={"VERIFICATION_LINK": email_verification_url}, + personalization_values={ + "VERIFICATION_LINK": email_verification_url + }, ) sent_email_count += 1 @@ -109,7 +140,9 @@ def get(self, request): if user_count > 0: sent_email_count = 0 - for email in user_queryset.values_list("email", flat=True).iterator(chunk_size=500): + for email in user_queryset.values_list("email", flat=True).iterator( + chunk_size=500 + ): email_verification_url = build_absolute_google_uri( request, reverse( @@ -122,7 +155,9 @@ def get(self, request): send_dotdigital_email( campaign_ids["verify_new_user_second_reminder"], [email], - personalization_values={"VERIFICATION_LINK": email_verification_url}, + personalization_values={ + "VERIFICATION_LINK": email_verification_url + }, ) sent_email_count += 1 @@ -157,14 +192,122 @@ def get(self, request): user_count -= User.objects.filter(is_active=True).count() logging.info(f"{user_count} unverified users anonymised.") - activity_today = DailyActivity.objects.get_or_create(date=datetime.now().date())[0] + activity_today = DailyActivity.objects.get_or_create( + date=datetime.now().date() + )[0] activity_today.anonymised_unverified_teachers = teacher_count activity_today.anonymised_unverified_independents = indy_count activity_today.save() TotalActivity.objects.update( - anonymised_unverified_teachers=F("anonymised_unverified_teachers") + teacher_count, - anonymised_unverified_independents=F("anonymised_unverified_independents") + indy_count, + anonymised_unverified_teachers=F("anonymised_unverified_teachers") + + teacher_count, + anonymised_unverified_independents=F( + "anonymised_unverified_independents" + ) + + indy_count, + ) + + return Response() + + +class FirstInactivityReminderView(CronMixin, APIView): + def get(self, request): + user_queryset = get_inactive_users(USER_1ST_INACTIVE_REMINDER_DAYS) + user_count = user_queryset.count() + + logging.info( + f"{user_count} inactive users after " + f"{USER_1ST_INACTIVE_REMINDER_DAYS} 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_dotdigital_email( + campaign_ids[ + "inactive_users_on_website_first_reminder" + ], + [email], + ) + + sent_email_count += 1 + except Exception as ex: + logging.exception(ex) + + logging.info( + f"Reminded {sent_email_count}/{user_count} inactive users." + ) + + return Response() + + +class SecondInactivityReminderView(CronMixin, APIView): + def get(self, request): + user_queryset = get_inactive_users(USER_2ND_INACTIVE_REMINDER_DAYS) + user_count = user_queryset.count() + + logging.info( + f"{user_count} inactive users after " + f"{USER_2ND_INACTIVE_REMINDER_DAYS} 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_dotdigital_email( + campaign_ids[ + "inactive_users_on_website_second_reminder" + ], + [email], + ) + + sent_email_count += 1 + except Exception as ex: + logging.exception(ex) + + logging.info( + f"Reminded {sent_email_count}/{user_count} inactive users." + ) + + return Response() + + +class FinalInactivityReminderView(CronMixin, APIView): + def get(self, request): + user_queryset = get_inactive_users(USER_FINAL_INACTIVE_REMINDER_DAYS) + user_count = user_queryset.count() + + logging.info( + f"{user_count} inactive users after " + f"{USER_FINAL_INACTIVE_REMINDER_DAYS} 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_dotdigital_email( + campaign_ids[ + "inactive_users_on_website_final_reminder" + ], + [email], + ) + + sent_email_count += 1 + except Exception as ex: + logging.exception(ex) + + logging.info( + f"Reminded {sent_email_count}/{user_count} inactive users." + ) + return Response()