From af3d2ed2252c812522d05d7b31ba7958ecf983df Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 18 Jan 2024 17:04:04 +0000 Subject: [PATCH] feat: add nudge braze email using commands --- .../apps/content_assignments/api.py | 4 +- .../apps/content_assignments/constants.py | 2 +- .../automatically_nudge_assignments.py | 132 +++++++++++++ .../test_automatically_nudge_assignments.py | 186 ++++++++++++++++++ 4 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py create mode 100644 enterprise_access/apps/content_assignments/management/commands/tests/test_automatically_nudge_assignments.py diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index 1b5f0f20a..0573b2f11 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -594,7 +594,7 @@ def expire_assignment(assignment, content_metadata, modify_assignment=True): current_date = now() if auto_cancellation_date and current_date > auto_cancellation_date: - assignment_expiry_reason = AssignmentAutomaticExpiredReason.NIENTY_DAYS_PASSED + assignment_expiry_reason = AssignmentAutomaticExpiredReason.NINETY_DAYS_PASSED elif enrollment_end_date and enrollment_end_date < current_date: assignment_expiry_reason = AssignmentAutomaticExpiredReason.ENROLLMENT_DATE_PASSED elif subsidy_expiration_datetime and subsidy_expiration_datetime < current_date: @@ -612,7 +612,7 @@ def expire_assignment(assignment, content_metadata, modify_assignment=True): logger.info('Modifying assignment %s to expired', assignment.uuid) assignment.state = LearnerContentAssignmentStateChoices.CANCELLED - if assignment_expiry_reason == AssignmentAutomaticExpiredReason.NIENTY_DAYS_PASSED: + if assignment_expiry_reason == AssignmentAutomaticExpiredReason.NINETY_DAYS_PASSED: assignment.clear_pii() assignment.clear_historical_pii() diff --git a/enterprise_access/apps/content_assignments/constants.py b/enterprise_access/apps/content_assignments/constants.py index 16f824d32..778d173a9 100644 --- a/enterprise_access/apps/content_assignments/constants.py +++ b/enterprise_access/apps/content_assignments/constants.py @@ -104,7 +104,7 @@ class AssignmentAutomaticExpiredReason: """ Reason for assignment automatic expiry. """ - NIENTY_DAYS_PASSED = 'NIENTY_DAYS_PASSED' + NINETY_DAYS_PASSED = 'NINETY_DAYS_PASSED' ENROLLMENT_DATE_PASSED = 'ENROLLMENT_DATE_PASSED' SUBSIDY_EXPIRED = 'SUBSIDY_EXPIRED' diff --git a/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py b/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py new file mode 100644 index 000000000..63f7739ce --- /dev/null +++ b/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py @@ -0,0 +1,132 @@ +""" +Management command to automatically nudge learners enrolled in a course in advance +""" + +import datetime +import logging + +from django.core.management.base import BaseCommand +from django.core.paginator import Paginator +from django.utils import timezone + +from enterprise_access.apps.content_assignments.api import send_reminder_email_for_pending_assignment +from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices +from enterprise_access.apps.content_assignments.content_metadata_api import get_content_metadata_for_assignments +from enterprise_access.apps.content_assignments.models import AssignmentConfiguration + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Automatically nudge learners who are 'days_before_course_start_date' days away from the course start_date + The default notification lead time without a provided `days_before_course_start_date` argument is 30 days + """ + help = ( + 'Spin off celery tasks to automatically send a braze email to ' + 'remind learners about an upcoming accepted assignment a certain number ' + 'of days in advanced determined by the "days_before_course_start_date" argument' + ) + + def add_arguments(self, parser): + """ + Entry point to add arguments. + """ + parser.add_argument( + '--dry-run', + action='store_true', + dest='dry_run', + default=False, + help='Dry Run, print log messages without spawning the celery tasks.', + ) + parser.add_argument( + '--days_before_course_start_date', + action='store_true', + dest='days_before_course_start_date', + default=30, + help='The amount of days before the course start date to send a nudge email through braze', + ) + + @staticmethod + def to_datetime(value): + """ + Return a datetime object of `value` if it is a str. + """ + if isinstance(value, str): + return datetime.datetime.strptime( + value, + "%Y-%m-%dT%H:%M:%SZ" + ).replace( + tzinfo=datetime.timezone.utc + ) + + return value + + def handle(self, *args, **options): + dry_run = options['dry_run'] + days_before_course_start_date = options['days_before_course_start_date'] + + for assignment_configuration in AssignmentConfiguration.objects.filter(active=True): + subsidy_access_policy = assignment_configuration.subsidy_access_policy + enterprise_catalog_uuid = subsidy_access_policy.catalog_uuid + + message = ( + '[AUTOMATICALLY_REMIND_ACCEPTED_ASSIGNMENTS_1] Assignment Configuration. UUID: [%s], ' + 'Policy: [%s], Catalog: [%s], Enterprise: [%s], dry_run [%s]', + ) + logger.info( + message, + assignment_configuration.uuid, + subsidy_access_policy.uuid, + enterprise_catalog_uuid, + assignment_configuration.enterprise_customer_uuid, + dry_run, + ) + + accepted_assignments = assignment_configuration.assignments.filter( + state=LearnerContentAssignmentStateChoices.ACCEPTED + ) + + paginator = Paginator(accepted_assignments, 100) + for page_number in paginator.page_range: + assignments = paginator.page(page_number) + + content_metadata_for_assignments = get_content_metadata_for_assignments( + enterprise_catalog_uuid, + assignments + ) + + for assignment in assignments: + content_metadata = content_metadata_for_assignments.get(assignment.content_key, {}) + start_date = content_metadata.get('normalized_metadata', {}).get('start_date') + course_type = content_metadata.get('course_type') + + # Determine if the day from today + days_before_course_state_date is + # equal to the day of the start date + # If they are equal, then send the nudge email, otherwise continue + datetime_start_date = self.to_datetime(start_date) + start_date_from_today = timezone.now() + timezone.timedelta( + days=days_before_course_start_date + ) + + can_send_nudge_notification_in_advance = datetime_start_date.day == start_date_from_today.day + + if course_type == 'executive-education-2u' and can_send_nudge_notification_in_advance: + message = ( + '[AUTOMATICALLY_REMIND_ACCEPTED_ASSIGNMENTS_2] assignment_configuration_uuid: [%s], ' + 'start_date: [%s], datetime_start_date: [%s], start_date_from_today: [%s], ' + 'days_before_course_start_date: [%s], can_send_nudge_notification_in_advance: [%s], ' + 'course_type: [%s], dry_run [%s]' + ) + logger.info(message, + assignment_configuration.uuid, + start_date, + datetime_start_date, + start_date_from_today, + days_before_course_start_date, + can_send_nudge_notification_in_advance, + course_type, + dry_run, + ) + if not dry_run: + send_reminder_email_for_pending_assignment.delay(assignment.uuid) diff --git a/enterprise_access/apps/content_assignments/management/commands/tests/test_automatically_nudge_assignments.py b/enterprise_access/apps/content_assignments/management/commands/tests/test_automatically_nudge_assignments.py new file mode 100644 index 000000000..0247ff29a --- /dev/null +++ b/enterprise_access/apps/content_assignments/management/commands/tests/test_automatically_nudge_assignments.py @@ -0,0 +1,186 @@ +""" +Tests for `automatically_nudge_assignments` management command. +""" + +from unittest import TestCase, mock +from unittest.mock import call +from uuid import uuid4 + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices +from enterprise_access.apps.content_assignments.management.commands import automatically_nudge_assignments +from enterprise_access.apps.content_assignments.models import LearnerContentAssignment +from enterprise_access.apps.content_assignments.tests.factories import ( + AssignmentConfigurationFactory, + LearnerContentAssignmentFactory +) +from enterprise_access.apps.subsidy_access_policy.tests.factories import AssignedLearnerCreditAccessPolicyFactory + +COMMAND_PATH = 'enterprise_access.apps.content_assignments.management.commands.automatically_nudge_assignments' + + +@pytest.mark.django_db +class TestAutomaticallyNudgeAssignmentCommand(TestCase): + """ + Tests `automatically_nudge_assignments` management command. + """ + + def setUp(self): + super().setUp() + self.command = automatically_nudge_assignments.Command() + + self.enterprise_uuid = uuid4() + self.assignment_configuration = AssignmentConfigurationFactory( + enterprise_customer_uuid=self.enterprise_uuid, + ) + self.assigned_learner_credit_policy = AssignedLearnerCreditAccessPolicyFactory( + display_name='An assigned learner credit policy, for the test customer.', + enterprise_customer_uuid=self.enterprise_uuid, + active=True, + assignment_configuration=self.assignment_configuration, + spend_limit=10000 * 100, + ) + + self.alice_assignment = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_configuration, + learner_email='alice@foo.com', + lms_user_id=None, + content_key='edX+edXPrivacy101', + content_title='edx: Privacy 101', + content_quantity=-123, + state=LearnerContentAssignmentStateChoices.ACCEPTED, + ) + + self.bob_assignment = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_configuration, + learner_email='bob@foo.com', + lms_user_id=None, + content_key='edX+edXAccessibility101', + content_title='edx: Accessibility 101', + content_quantity=-456, + state=LearnerContentAssignmentStateChoices.ACCEPTED, + ) + + @mock.patch(COMMAND_PATH + '.get_content_metadata_for_assignments') + @mock.patch('enterprise_access.apps.content_assignments.api.send_reminder_email_for_pending_assignment.delay') + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') + def test_command_dry_run( + self, + mock_subsidy_client, + mock_send_reminder_email_for_pending_assignment_task, + mock_content_metadata_for_assignments, + ): + """ + Verify that management command work as expected in dry run mode. + """ + enrollment_end = timezone.now() - timezone.timedelta(days=5) + enrollment_end = enrollment_end.replace(microsecond=0) + subsidy_expiry = timezone.now() + timezone.timedelta(days=5) + subsidy_expiry = subsidy_expiry.replace(microsecond=0) + start_date = timezone.now() + timezone.timedelta(days=30) + start_date = start_date.replace(microsecond=0) + end_date = timezone.now() + timezone.timedelta(days=180) + end_date = end_date.replace(microsecond=0) + + mock_subsidy_client.retrieve_subsidy.return_value = { + 'enterprise_customer_uuid': str(self.enterprise_uuid), + 'expiration_datetime': subsidy_expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'is_active': True, + } + mock_content_metadata_for_assignments.return_value = { + 'edX+edXAccessibility101': { + 'key': 'edX+edXAccessibility101', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'content_price': 123, + }, + 'course_type': 'executive-education-2u', + }, + 'edX+edXPrivacy101': { + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'enroll_by_date': enrollment_end.strftime("%Y-%m-%d %H:%M"), + 'content_price': 321, + }, + 'course_type': 'executive-education-2u', + } + } + + all_assignment = LearnerContentAssignment.objects.all() + accepted_assignments = LearnerContentAssignment.objects.filter( + state=LearnerContentAssignmentStateChoices.ACCEPTED + ) + # verify that all assignments are in `allocated` state + assert all_assignment.count() == accepted_assignments.count() + + call_command(self.command, '--dry-run') + + mock_send_reminder_email_for_pending_assignment_task.assert_not_called() + + @mock.patch(COMMAND_PATH + '.get_content_metadata_for_assignments') + @mock.patch('enterprise_access.apps.content_assignments.api.send_reminder_email_for_pending_assignment.delay') + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') + def test_command( + self, + mock_subsidy_client, + mock_send_reminder_email_for_pending_assignment_task, + mock_content_metadata_for_assignments, + ): + """ + Verify that management command work as expected in dry run mode. + """ + enrollment_end = timezone.now() + timezone.timedelta(days=5) + enrollment_end = enrollment_end.replace(microsecond=0) + subsidy_expiry = timezone.now() - timezone.timedelta(days=5) + subsidy_expiry = subsidy_expiry.replace(microsecond=0) + start_date = timezone.now() + timezone.timedelta(days=14) + start_date = start_date.replace(microsecond=0) + end_date = timezone.now() + timezone.timedelta(days=180) + end_date = end_date.replace(microsecond=0) + + mock_subsidy_client.retrieve_subsidy.return_value = { + 'enterprise_customer_uuid': str(self.enterprise_uuid), + 'expiration_datetime': subsidy_expiry.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'is_active': True, + } + mock_content_metadata_for_assignments.return_value = { + 'edX+edXAccessibility101': { + 'key': 'edX+edXAccessibility101', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'content_price': 123, + }, + 'course_type': 'executive-education-2u', + }, + 'edX+edXPrivacy101': { + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'enroll_by_date': enrollment_end.strftime("%Y-%m-%d %H:%M"), + 'content_price': 321, + }, + 'course_type': 'executive-education-2u', + } + } + + all_assignment = LearnerContentAssignment.objects.all() + accepted_assignments = LearnerContentAssignment.objects.filter( + state=LearnerContentAssignmentStateChoices.ACCEPTED + ) + # verify that all assignments are in `accepted` state + assert all_assignment.count() == accepted_assignments.count() + + call_command(self.command, days_before_course_start_date=14) + + mock_send_reminder_email_for_pending_assignment_task.assert_has_calls([ + call(self.alice_assignment.uuid), + call(self.bob_assignment.uuid), + ])