From c735a3964a875c331390d8d8d5b5940104d0381f Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 18 Jan 2024 17:04:04 +0000 Subject: [PATCH 1/5] feat: add nudge braze email using commands --- .../apps/content_assignments/api.py | 4 +- .../apps/content_assignments/constants.py | 2 +- .../automatically_nudge_assignments.py | 135 ++++ .../test_automatically_nudge_assignments.py | 587 ++++++++++++++++++ .../apps/content_assignments/utils.py | 14 + 5 files changed, 739 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 1b5f0f20..0573b2f1 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 16f824d3..778d173a 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 00000000..2b0c8605 --- /dev/null +++ b/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py @@ -0,0 +1,135 @@ +""" +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 +from enterprise_access.apps.content_assignments.utils import are_dates_matching_with_day_offset + +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') + + is_executive_education_course_type = course_type in ( + 'executive-education-2u', 'executive-education') + + # Determine if the date from today + days_before_course_state_date is + # equal to the date of the start date + # If they are equal, then send the nudge email, otherwise continue + datetime_start_date = self.to_datetime(start_date) + can_send_nudge_notification_in_advance = are_dates_matching_with_day_offset( + days_offset=days_before_course_start_date, + target_date=datetime_start_date, + date_to_offset=timezone.now(), + ) + + if is_executive_education_course_type and can_send_nudge_notification_in_advance: + message = ( + '[AUTOMATICALLY_REMIND_ACCEPTED_ASSIGNMENTS_2] assignment_configuration_uuid: [%s], ' + 'start_date: [%s], datetime_start_date: [%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, + 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 00000000..91f29b25 --- /dev/null +++ b/enterprise_access/apps/content_assignments/management/commands/tests/test_automatically_nudge_assignments.py @@ -0,0 +1,587 @@ +""" +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.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, + ) + + self.rob_assignment = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_configuration, + learner_email='rob@foo.com', + lms_user_id=None, + content_key='edX+edXQuadrilateral306090', + content_title='edx: Quadrilateral 306090', + content_quantity=-456, + state=LearnerContentAssignmentStateChoices.ACCEPTED, + ) + self.richard_assignment = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_configuration, + learner_email='richard@foo.com', + lms_user_id=None, + content_key='edX+edXTesseract4D', + content_title='edx: Tesseract 4D', + content_quantity=-456, + state=LearnerContentAssignmentStateChoices.ACCEPTED, + ) + + self.ella_assignment = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_configuration, + learner_email='ella@foo.com', + lms_user_id=None, + content_key='edX+edXIsoscelesPyramid2012', + content_title='edx: IsoscelesPyramid 2012', + content_quantity=-456, + state=LearnerContentAssignmentStateChoices.ALLOCATED, + ) + self.bella_assignment = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_configuration, + learner_email='bella@foo.com', + lms_user_id=None, + content_key='edX+edXBeeHivesAlive0220', + content_title='edx: BeeHivesAlive 0220', + content_quantity=-456, + state=LearnerContentAssignmentStateChoices.CANCELLED, + ) + + @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', + }, + 'edX+edXTesseract4D': { + 'key': 'edX+edXTesseract4D', + '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+edXQuadrilateral306090': { + 'key': 'edX+edXQuadrilateral306090', + '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+edXIsoscelesPyramid2012': { + 'key': 'edX+edXIsoscelesPyramid2012', + '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+edXBeeHivesAlive0220': { + 'key': 'edX+edXBeeHivesAlive0220', + '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', + }, + } + + 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. + """ + 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', + }, + 'edX+edXTesseract4D': { + 'key': 'edX+edXTesseract4D', + '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+edXQuadrilateral306090': { + 'key': 'edX+edXQuadrilateral306090', + '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+edXIsoscelesPyramid2012': { + 'key': 'edX+edXIsoscelesPyramid2012', + '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+edXBeeHivesAlive0220': { + 'key': 'edX+edXBeeHivesAlive0220', + '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', + }, + } + + 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), + ]) + + @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_multiple_assignment_dates( + self, + mock_subsidy_client, + mock_send_reminder_email_for_pending_assignment_task, + mock_content_metadata_for_assignments, + ): + """ + Verify that management command work as expected given multiple dates have been mocked. + """ + 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) + + # Three nonpassing dates for assignments + start_date_beyond_30_days = timezone.now() + timezone.timedelta(days=90) + start_date_beyond_30_days = start_date_beyond_30_days.replace(microsecond=0) + + start_date_between_30_and_14_days = timezone.now() + timezone.timedelta(days=9) + start_date_between_30_and_14_days = start_date_between_30_and_14_days.replace(microsecond=0) + + start_date_already_started = timezone.now() + timezone.timedelta(days=-9) + start_date_already_started = start_date_already_started.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_between_30_and_14_days.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+edXTesseract4D': { + 'key': 'edX+edXTesseract4D', + 'normalized_metadata': { + 'start_date': start_date_already_started.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+edXQuadrilateral306090': { + 'key': 'edX+edXQuadrilateral306090', + 'normalized_metadata': { + 'start_date': start_date_beyond_30_days.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', + }, + 'edX+edXIsoscelesPyramid2012': { + 'key': 'edX+edXIsoscelesPyramid2012', + '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+edXBeeHivesAlive0220': { + 'key': 'edX+edXBeeHivesAlive0220', + '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', + }, + } + + 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), + ]) + + @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_multiple_assignment_course_types( + self, + mock_subsidy_client, + mock_send_reminder_email_for_pending_assignment_task, + mock_content_metadata_for_assignments, + ): + """ + Verify that management command work as expected given a course_type is not executive-education. + """ + 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, + } + + # Update course_type to check for only 'executive-education' or 'executive-education-2u' course types + 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': 'verified-audit', + }, + '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', + }, + 'edX+edXTesseract4D': { + 'key': 'edX+edXTesseract4D', + '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': 'professional', + }, + 'edX+edXQuadrilateral306090': { + 'key': 'edX+edXQuadrilateral306090', + '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': 'bootcamp-2u', + }, + 'edX+edXIsoscelesPyramid2012': { + 'key': 'edX+edXIsoscelesPyramid2012', + '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+edXBeeHivesAlive0220': { + 'key': 'edX+edXBeeHivesAlive0220', + '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', + }, + } + + 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), + ]) + + @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_multiple_assignment_states( + self, + mock_subsidy_client, + mock_send_reminder_email_for_pending_assignment_task, + mock_content_metadata_for_assignments, + ): + """ + Verify that management command work as expected given the state of the course is allocated, + cancelled or errored state. + """ + 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) + + # Update bobs assignment state + self.bob_assignment.state = LearnerContentAssignmentStateChoices.ERRORED + + 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+edXTesseract4D': { + 'key': 'edX+edXTesseract4D', + '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+edXQuadrilateral306090': { + 'key': 'edX+edXQuadrilateral306090', + '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', + }, + 'edX+edXIsoscelesPyramid2012': { + 'key': 'edX+edXIsoscelesPyramid2012', + '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+edXBeeHivesAlive0220': { + 'key': 'edX+edXBeeHivesAlive0220', + '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', + }, + } + + 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), + ]) diff --git a/enterprise_access/apps/content_assignments/utils.py b/enterprise_access/apps/content_assignments/utils.py index e8ec4155..32f009fa 100644 --- a/enterprise_access/apps/content_assignments/utils.py +++ b/enterprise_access/apps/content_assignments/utils.py @@ -3,6 +3,8 @@ """ import traceback +from django.utils import timezone + def chunks(a_list, chunk_size): """ @@ -15,3 +17,15 @@ def chunks(a_list, chunk_size): def format_traceback(exception): trace = ''.join(traceback.format_tb(exception.__traceback__)) return f'{exception}\n{trace}' + + +def are_dates_matching_with_day_offset(days_offset, target_date, date_to_offset): + """ + Takes an integer number of days to offset from the date_to_offset to determine if + the target_date matches the date_to_offset + days_offset date + + The target_date and date_to_offset arguments are UTC timezone objects + """ + offset_date = date_to_offset + timezone.timedelta(days=days_offset) + are_dates_matching = target_date.strftime('%Y-%m-%d') == offset_date.strftime('%Y-%m-%d') + return are_dates_matching From 03ff10953e994665a73b8fd41f58d28ac4c562af Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 23 Jan 2024 21:43:05 +0000 Subject: [PATCH 2/5] chore: PR feedback --- .../content_metadata_api.py | 22 +++- .../automatically_nudge_assignments.py | 43 ++++---- .../test_automatically_nudge_assignments.py | 100 +++++++----------- .../apps/content_assignments/tasks.py | 46 ++++++++ .../apps/content_assignments/utils.py | 14 --- enterprise_access/settings/base.py | 1 + 6 files changed, 129 insertions(+), 97 deletions(-) diff --git a/enterprise_access/apps/content_assignments/content_metadata_api.py b/enterprise_access/apps/content_assignments/content_metadata_api.py index 0a8fd1b4..d434cb53 100644 --- a/enterprise_access/apps/content_assignments/content_metadata_api.py +++ b/enterprise_access/apps/content_assignments/content_metadata_api.py @@ -2,7 +2,9 @@ API file interacting with assignment metadata (created to avoid a circular import error) """ -from datetime import datetime +import datetime + +from django.utils import timezone from enterprise_access.apps.content_metadata.api import get_and_cache_catalog_content_metadata @@ -60,7 +62,7 @@ def get_human_readable_date(datetime_string, output_pattern=DEFAULT_STRFTIME_PAT return None -def parse_datetime_string(datetime_string): +def parse_datetime_string(datetime_string, **set_to_utc): """ Given a datetime string value from some content metadata record, parse it into a datetime object. @@ -71,7 +73,10 @@ def parse_datetime_string(datetime_string): last_exception = None for input_pattern in DATE_INPUT_PATTERNS: try: - return datetime.strptime(datetime_string, input_pattern) + formatted_date = datetime.datetime.strptime(datetime_string, input_pattern) + if set_to_utc: + return formatted_date.replace(tzinfo=datetime.timezone.utc) + return formatted_date except ValueError as exc: last_exception = exc @@ -97,3 +102,14 @@ def get_course_partners(course_metadata): if len(names) == 2: return ' and '.join(names) return ', '.join(names[:-1]) + ', and ' + names[-1] + + +def is_date_n_days_from_now(target_datetime, num_days): + """ + Takes an integer number of days to offset from the date_to_offset to determine if + the target_date matches the date_to_offset + days_offset date + + The target_date and date_to_offset arguments are UTC timezone objects + """ + future_datetime = timezone.now() + timezone.timedelta(days=num_days) + return target_datetime.date() == future_datetime.date() 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 index 2b0c8605..4e10c639 100644 --- a/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py +++ b/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py @@ -7,13 +7,15 @@ 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.content_metadata_api import ( + get_content_metadata_for_assignments, + is_date_n_days_from_now, + parse_datetime_string +) from enterprise_access.apps.content_assignments.models import AssignmentConfiguration -from enterprise_access.apps.content_assignments.utils import are_dates_matching_with_day_offset +from enterprise_access.apps.content_assignments.tasks import send_exec_ed_enrollment_warmer logger = logging.getLogger(__name__) @@ -102,17 +104,15 @@ def handle(self, *args, **options): start_date = content_metadata.get('normalized_metadata', {}).get('start_date') course_type = content_metadata.get('course_type') - is_executive_education_course_type = course_type in ( - 'executive-education-2u', 'executive-education') + is_executive_education_course_type = course_type == 'executive-education-2u' # Determine if the date from today + days_before_course_state_date is # equal to the date of the start date # If they are equal, then send the nudge email, otherwise continue - datetime_start_date = self.to_datetime(start_date) - can_send_nudge_notification_in_advance = are_dates_matching_with_day_offset( - days_offset=days_before_course_start_date, - target_date=datetime_start_date, - date_to_offset=timezone.now(), + datetime_start_date = parse_datetime_string(start_date, set_to_utc=True) + can_send_nudge_notification_in_advance = is_date_n_days_from_now( + target_datetime=datetime_start_date, + num_days=days_before_course_start_date ) if is_executive_education_course_type and can_send_nudge_notification_in_advance: @@ -122,14 +122,15 @@ def handle(self, *args, **options): '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, - days_before_course_start_date, - can_send_nudge_notification_in_advance, - course_type, - dry_run, - ) + logger.info( + message, + assignment_configuration.uuid, + start_date, + datetime_start_date, + 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) + send_exec_ed_enrollment_warmer.delay(assignment.uuid, days_before_course_start_date) 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 index 91f29b25..9b8721cd 100644 --- 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 @@ -102,7 +102,7 @@ def setUp(self): ) @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.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') def test_command_dry_run( self, @@ -113,14 +113,10 @@ def test_command_dry_run( """ 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) + enrollment_end = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + subsidy_expiry = timezone.now().replace(microsecond=0) + timezone.timedelta(days=5) + start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=30) + end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) mock_subsidy_client.retrieve_subsidy.return_value = { 'enterprise_customer_uuid': str(self.enterprise_uuid), @@ -194,25 +190,21 @@ def test_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.content_assignments.tasks.send_exec_ed_enrollment_warmer.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_send_exec_ed_enrollment_warmer_task, mock_content_metadata_for_assignments, ): """ Verify that management command work as expected. """ - 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) + enrollment_end = timezone.now().replace(microsecond=0) + timezone.timedelta(days=5) + subsidy_expiry = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14) + end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) mock_subsidy_client.retrieve_subsidy.return_value = { 'enterprise_customer_uuid': str(self.enterprise_uuid), @@ -237,7 +229,7 @@ def test_command( 'enroll_by_date': enrollment_end.strftime("%Y-%m-%d %H:%M"), 'content_price': 321, }, - 'course_type': 'executive-education', + 'course_type': 'executive-education-2u', }, 'edX+edXTesseract4D': { 'key': 'edX+edXTesseract4D', @@ -283,31 +275,29 @@ def test_command( 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), + mock_send_exec_ed_enrollment_warmer_task.assert_has_calls([ + call(self.alice_assignment.uuid, 14), + call(self.bob_assignment.uuid, 14), + call(self.rob_assignment.uuid, 14), + call(self.richard_assignment.uuid, 14) ]) @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.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') def test_command_multiple_assignment_dates( self, mock_subsidy_client, - mock_send_reminder_email_for_pending_assignment_task, + mock_send_exec_ed_enrollment_warmer_task, mock_content_metadata_for_assignments, ): """ Verify that management command work as expected given multiple dates have been mocked. """ - 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) + enrollment_end = timezone.now().replace(microsecond=0) + timezone.timedelta(days=5) + subsidy_expiry = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14) + end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) # Three nonpassing dates for assignments start_date_beyond_30_days = timezone.now() + timezone.timedelta(days=90) @@ -388,30 +378,26 @@ def test_command_multiple_assignment_dates( 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), + mock_send_exec_ed_enrollment_warmer_task.assert_has_calls([ + call(self.alice_assignment.uuid, 14), ]) @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.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') def test_command_multiple_assignment_course_types( self, mock_subsidy_client, - mock_send_reminder_email_for_pending_assignment_task, + mock_send_exec_ed_enrollment_warmer_task, mock_content_metadata_for_assignments, ): """ Verify that management command work as expected given a course_type is not executive-education. """ - 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) + enrollment_end = timezone.now().replace(microsecond=0) + timezone.timedelta(days=5) + subsidy_expiry = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14) + end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) mock_subsidy_client.retrieve_subsidy.return_value = { 'enterprise_customer_uuid': str(self.enterprise_uuid), @@ -484,31 +470,27 @@ def test_command_multiple_assignment_course_types( 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), + mock_send_exec_ed_enrollment_warmer_task.assert_has_calls([ + call(self.alice_assignment.uuid, 14), ]) @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.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') def test_command_multiple_assignment_states( self, mock_subsidy_client, - mock_send_reminder_email_for_pending_assignment_task, + mock_send_exec_ed_enrollment_warmer_task, mock_content_metadata_for_assignments, ): """ Verify that management command work as expected given the state of the course is allocated, cancelled or errored state. """ - 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) + enrollment_end = timezone.now().replace(microsecond=0) + timezone.timedelta(days=5) + subsidy_expiry = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14) + end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) # Update bobs assignment state self.bob_assignment.state = LearnerContentAssignmentStateChoices.ERRORED @@ -582,6 +564,6 @@ def test_command_multiple_assignment_states( 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), + mock_send_exec_ed_enrollment_warmer_task.assert_has_calls([ + call(self.alice_assignment.uuid, 14), ]) diff --git a/enterprise_access/apps/content_assignments/tasks.py b/enterprise_access/apps/content_assignments/tasks.py index d784bf6a..45f6a92b 100644 --- a/enterprise_access/apps/content_assignments/tasks.py +++ b/enterprise_access/apps/content_assignments/tasks.py @@ -366,6 +366,52 @@ def send_cancel_email_for_pending_assignment(cancelled_assignment_uuid): logger.info(f'Sent braze campaign cancelled uuid={campaign_uuid} message for assignment {assignment}') +# pylint: disable=abstract-method +class SendExecutiveEducationNudgeTask(BaseAssignmentRetryAndErrorActionTask): + """ + Base class for the ``send_exec_ed_enrollment_warmer`` task. + """ + def add_errored_action(self, assignment, exc): + assignment.add_errored_reminded_action(exc) + + +@shared_task(base=SendExecutiveEducationNudgeTask) +def send_exec_ed_enrollment_warmer(assignment_uuid, days_before_course_start_date): + """ + Send email via braze for nudging users of their pending accepted assignments + Args: + assignment_uuid: (string) the subsidy request uuid + """ + assignment = _get_assignment_or_raise(assignment_uuid) + + campaign_sender = BrazeCampaignSender(assignment) + braze_trigger_properties = campaign_sender.get_properties( + 'contact_admin_link', + 'organization', + 'course_title', + 'enrollment_deadline', + 'start_date', + 'course_partner', + 'course_card_image', + 'learner_portal_link', + 'action_required_by', + ) + + braze_trigger_properties['days_before_course_start_date'] = days_before_course_start_date + + campaign_uuid = settings.BRAZE_ASSIGNMENT_NUDGE_EXEC_ED_ACCEPTED_ASSIGNMENT_CAMPAIGN + + campaign_sender.send_campaign_message( + braze_trigger_properties, + campaign_uuid, + ) + logger.info( + f'Sent braze campaign nudge reminder at ' + f'days_before_course_start_date={days_before_course_start_date} ' + f'uuid={campaign_uuid} message for assignment {assignment}' + ) + + # pylint: disable=abstract-method class SendReminderEmailTask(BaseAssignmentRetryAndErrorActionTask): """ diff --git a/enterprise_access/apps/content_assignments/utils.py b/enterprise_access/apps/content_assignments/utils.py index 32f009fa..e8ec4155 100644 --- a/enterprise_access/apps/content_assignments/utils.py +++ b/enterprise_access/apps/content_assignments/utils.py @@ -3,8 +3,6 @@ """ import traceback -from django.utils import timezone - def chunks(a_list, chunk_size): """ @@ -17,15 +15,3 @@ def chunks(a_list, chunk_size): def format_traceback(exception): trace = ''.join(traceback.format_tb(exception.__traceback__)) return f'{exception}\n{trace}' - - -def are_dates_matching_with_day_offset(days_offset, target_date, date_to_offset): - """ - Takes an integer number of days to offset from the date_to_offset to determine if - the target_date matches the date_to_offset + days_offset date - - The target_date and date_to_offset arguments are UTC timezone objects - """ - offset_date = date_to_offset + timezone.timedelta(days=days_offset) - are_dates_matching = target_date.strftime('%Y-%m-%d') == offset_date.strftime('%Y-%m-%d') - return are_dates_matching diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 66beb071..7338d19b 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -471,6 +471,7 @@ def root(*path_fragments): BRAZE_ASSIGNMENT_NOTIFICATION_CAMPAIGN = '' BRAZE_ASSIGNMENT_REMINDER_NOTIFICATION_CAMPAIGN = '' BRAZE_ASSIGNMENT_REMINDER_POST_LOGISTRATION_NOTIFICATION_CAMPAIGN = '' +BRAZE_ASSIGNMENT_NUDGE_EXEC_ED_ACCEPTED_ASSIGNMENT_CAMPAIGN = '' BRAZE_ASSIGNMENT_CANCELLED_NOTIFICATION_CAMPAIGN = '' BRAZE_ASSIGNMENT_AUTOMATIC_CANCELLATION_NOTIFICATION_CAMPAIGN = '' From e1d2c2d327050e6e7a3fe32420bfa1a3857411b4 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Fri, 26 Jan 2024 18:02:44 +0000 Subject: [PATCH 3/5] feat: Nudge API --- .../content_assignments/assignment.py | 29 +++++ .../content_assignments/assignments_admin.py | 40 ++++++- .../apps/content_assignments/api.py | 101 +++++++++++++++++- 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/enterprise_access/apps/api/serializers/content_assignments/assignment.py b/enterprise_access/apps/api/serializers/content_assignments/assignment.py index 006a82e7..92f26c9f 100644 --- a/enterprise_access/apps/api/serializers/content_assignments/assignment.py +++ b/enterprise_access/apps/api/serializers/content_assignments/assignment.py @@ -173,6 +173,35 @@ class LearnerContentAssignmentActionRequestSerializer(serializers.Serializer): ) +class LearnerContentAssignmentNudgeRequestSerializer(serializers.Serializer): + """ + Request serializer to validate nudge endpoint query params. + + For view: LearnerContentAssignmentAdminViewSet.nudge + """ + assignment_uuids = serializers.ListField( + child=serializers.UUIDField(), + allow_empty=False + ) + days_before_course_start_date = serializers.IntegerField( + min_value=1 + ) + + +class LearnerContentAssignmentNudgeResponseSerializer(serializers.Serializer): + """ + Response serializer for nudge endpoint. + + For view: LearnerContentAssignmentAdminViewSet.nudge + """ + nudged_assignment_uuids = serializers.ListField( + child=serializers.UUIDField(), + ) + unnudged_assignment_uuids = serializers.ListField( + child=serializers.UUIDField(), + ) + + class ContentMetadataForAssignmentSerializer(serializers.Serializer): """ Serializer to help return additional content metadata for assignments. These fields should diff --git a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py index 0e6c798b..e5c86117 100644 --- a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py +++ b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py @@ -14,7 +14,9 @@ from enterprise_access.apps.api import filters, serializers, utils from enterprise_access.apps.api.serializers.content_assignments.assignment import ( - LearnerContentAssignmentActionRequestSerializer + LearnerContentAssignmentActionRequestSerializer, + LearnerContentAssignmentNudgeRequestSerializer, + LearnerContentAssignmentNudgeResponseSerializer ) from enterprise_access.apps.api.v1.views.utils import PaginationWithPageCount from enterprise_access.apps.content_assignments import api as assignments_api @@ -347,3 +349,39 @@ def remind_all(self, request, *args, **kwargs): return Response(status=status.HTTP_202_ACCEPTED) except Exception: # pylint: disable=broad-except return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + + @extend_schema( + tags=[CONTENT_ASSIGNMENT_ADMIN_CRUD_API_TAG], + summary='Nudge assignments by UUID.', + request=LearnerContentAssignmentNudgeRequestSerializer, + parameters=None, + responses={ + status.HTTP_200_OK: LearnerContentAssignmentNudgeResponseSerializer, + status.HTTP_422_UNPROCESSABLE_ENTITY: None, + } + ) + @permission_required(CONTENT_ASSIGNMENT_ADMIN_WRITE_PERMISSION, fn=assignment_admin_permission_fn) + @action(detail=False, methods=['post']) + def nudge(self, request, *args, **kwargs): + """ + Send nudges to a list of learners with associated ``LearnerContentAssignment`` + record by list of uuids. + + ``` + Raises: + 400 If ``assignment_uuids`` list length is 0 or the value for ``days_before_course_start_date`` is less than 1 + 422 If the nudge_assignments call fails for any other reason + ``` + """ + serializer = LearnerContentAssignmentNudgeRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + assignments = self.get_queryset().filter( + assignment_configuration__uuid=self.requested_assignment_configuration_uuid, + uuid__in=serializer.data['assignment_uuids'], + ) + days_before_course_start_date = serializer.data['days_before_course_start_date'] + try: + response = assignments_api.nudge_assignments(assignments, days_before_course_start_date) + return Response(data=response, status=status.HTTP_200_OK) + except Exception: # pylint: disable=broad-except + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index 8d3c6124..f6359725 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -14,7 +14,15 @@ from django.utils.timezone import now from pytz import UTC -from enterprise_access.apps.content_assignments.tasks import send_reminder_email_for_pending_assignment +from enterprise_access.apps.content_assignments.content_metadata_api import ( + get_content_metadata_for_assignments, + is_date_n_days_from_now, + parse_datetime_string +) +from enterprise_access.apps.content_assignments.tasks import ( + send_exec_ed_enrollment_warmer, + send_reminder_email_for_pending_assignment +) from enterprise_access.apps.core.models import User from enterprise_access.apps.subsidy_access_policy.content_metadata_api import get_and_cache_content_metadata @@ -570,6 +578,97 @@ def remind_assignments(assignments: Iterable[LearnerContentAssignment]) -> dict: } +def nudge_assignments(assignments, days_before_course_start_date): + """ + Nudge assignments. + + This is a no-op for assignments in the following state: [allocated, errored, canceled, expired]. We only allow + assignments which are in the accepted state. + + + Args: + assignment: An assignment to nudge + days_before_course_start_date: Number of days prior to start date to nudge individual assignment + content_metadata: Content metadata of the assigned course to be nudged + """ + nudged_assignment_uuids = [] + unnudged_assignment_uuids = [] + + for assignment in assignments: + if assignment.state == LearnerContentAssignmentStateChoices.ACCEPTED: + assignment_configuration = AssignmentConfiguration.objects.filter(uuid=assignment.assignment_configuration) + subsidy_access_policy = assignment_configuration.subsidy_access_policy + enterprise_catalog_uuid = subsidy_access_policy.catalog_uuid + + message = ( + '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_1] ' + 'Assignment Configuration. UUID: [%s], ' + 'Policy: [%s], Catalog: [%s], Enterprise: [%s]', + ) + logger.info( + message, + assignment_configuration.uuid, + subsidy_access_policy.uuid, + enterprise_catalog_uuid, + assignment_configuration.enterprise_customer_uuid, + ) + + content_metadata_for_assignments = get_content_metadata_for_assignments( + enterprise_catalog_uuid, + [assignment], + ) + 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') + + is_executive_education_course_type = course_type == 'executive-education-2u' + + # Determine if the date from today + days_before_course_state_date is + # equal to the date of the start date + # If they are equal, then send the nudge email, otherwise continue + datetime_start_date = parse_datetime_string(start_date, set_to_utc=True) + can_send_nudge_notification_in_advance = is_date_n_days_from_now( + target_datetime=datetime_start_date, + num_days=days_before_course_start_date + ) + + if is_executive_education_course_type and can_send_nudge_notification_in_advance: + message = ( + '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_2] ', + 'assignment_configuration_uuid: [%s], start_date: [%s], datetime_start_date: [%s], ' + 'days_before_course_start_date: [%s], can_send_nudge_notification_in_advance: [%s], ' + 'course_type: [%s]' + ) + logger.info( + message, + assignment.assignment_configuration, + start_date, + datetime_start_date, + days_before_course_start_date, + can_send_nudge_notification_in_advance, + course_type, + ) + send_exec_ed_enrollment_warmer.delay(assignment.uuid, days_before_course_start_date) + nudged_assignment_uuids.append(assignment.uuid) + else: + unnudged_assignment_uuids.append(assignment.uuid) + else: + message = ( + '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_ERROR] ' + 'assignment: [%s], ' + 'days_before_course_start_date: [%s]' + ) + logger.info( + message, + assignment, days_before_course_start_date + ) + unnudged_assignment_uuids.append(assignment.uuid) + return { + 'nudged_assignment_uuids': nudged_assignment_uuids, + 'unnudged_assignment_uuids': unnudged_assignment_uuids, + } + + def expire_assignment(assignment, content_metadata, modify_assignment=True): """ If applicable, retires the given assignment, returning an expiration reason. From 197af37019eb5ba3821cde279a12a8ec2882f0da Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 30 Jan 2024 08:16:58 +0000 Subject: [PATCH 4/5] chore: PR feedback 2 --- .../content_assignments/assignment.py | 2 + .../api/v1/tests/test_assignment_views.py | 114 ++++++++++++- .../content_assignments/assignments_admin.py | 15 +- .../apps/content_assignments/api.py | 150 +++++++++++------- .../content_metadata_api.py | 12 +- .../automatically_nudge_assignments.py | 1 + 6 files changed, 226 insertions(+), 68 deletions(-) diff --git a/enterprise_access/apps/api/serializers/content_assignments/assignment.py b/enterprise_access/apps/api/serializers/content_assignments/assignment.py index 92f26c9f..883cda35 100644 --- a/enterprise_access/apps/api/serializers/content_assignments/assignment.py +++ b/enterprise_access/apps/api/serializers/content_assignments/assignment.py @@ -196,9 +196,11 @@ class LearnerContentAssignmentNudgeResponseSerializer(serializers.Serializer): """ nudged_assignment_uuids = serializers.ListField( child=serializers.UUIDField(), + allow_empty=False ) unnudged_assignment_uuids = serializers.ListField( child=serializers.UUIDField(), + allow_empty=True ) diff --git a/enterprise_access/apps/api/v1/tests/test_assignment_views.py b/enterprise_access/apps/api/v1/tests/test_assignment_views.py index a8b6d777..e66b1b18 100644 --- a/enterprise_access/apps/api/v1/tests/test_assignment_views.py +++ b/enterprise_access/apps/api/v1/tests/test_assignment_views.py @@ -5,6 +5,7 @@ from uuid import UUID, uuid4 import ddt +from django.utils import timezone from rest_framework import status from rest_framework.reverse import reverse @@ -81,7 +82,6 @@ def setUp(self): super().setUp() # Start in an unauthenticated state. self.client.logout() - # This assignment has just been allocated, so its lms_user_id is null. self.assignment_allocated_pre_link = LearnerContentAssignmentFactory( state=LearnerContentAssignmentStateChoices.ALLOCATED, @@ -96,6 +96,9 @@ def setUp(self): lms_user_id=TEST_OTHER_LMS_USER_ID, transaction_uuid=None, assignment_configuration=self.assignment_configuration, + content_key='edX+edXPrivacy101', + content_quantity=-321, + content_title='edx: Privacy 101' ) self.assignment_allocated_post_link.add_successful_linked_action() self.assignment_allocated_post_link.add_successful_notified_action() @@ -118,6 +121,9 @@ def setUp(self): lms_user_id=TEST_OTHER_LMS_USER_ID, transaction_uuid=uuid4(), assignment_configuration=self.assignment_configuration, + content_key='edX+edXAccessibility101', + content_quantity=-123, + content_title='edx: Accessibility 101' ) self.assignment_accepted.add_successful_linked_action() self.assignment_accepted.add_successful_notified_action() @@ -261,6 +267,20 @@ def test_admin_assignment_readwrite_views_unauthorized_forbidden(self, role_cont response = self.client.post(cancel_url, query_params) assert response.status_code == expected_response_code + # Call the nudge endpoint. + nudge_kwargs = { + 'assignment_configuration_uuid': str(TEST_ASSIGNMENT_CONFIG_UUID), + } + nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs) + query_params = { + 'assignment_uuids': [str(self.assignment_accepted.uuid)], + 'days_before_course_start_date': 3 + } + + # nudge endpoint + response = self.client.post(nudge_url, query_params) + assert response.status_code == expected_response_code + @ddt.ddt class TestAssignmentsUnauthorizedCRUD(CRUDViewTestMixin, APITest): @@ -623,6 +643,98 @@ def test_cancel(self, mock_send_cancel_email): assert self.assignment_allocated_post_link.state == LearnerContentAssignmentStateChoices.CANCELLED mock_send_cancel_email.delay.assert_called_once_with(self.assignment_allocated_post_link.uuid) + @mock.patch('enterprise_access.apps.content_assignments.api.get_content_metadata_for_assignments') + @mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') + def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for_assignments): + """ + Test that the cancel view cancels the assignment and returns an appropriate response with 200 status code and + the expected results of serialization. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14) + end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) + enrollment_end = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + + # Mock content metadata for assignment + 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', + }, + } + # Call the nudge endpoint. + nudge_kwargs = { + 'assignment_configuration_uuid': self.assignment_configuration.uuid, + } + nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs) + query_params = { + 'assignment_uuids': [str(self.assignment_accepted.uuid)], + 'days_before_course_start_date': 14 + } + + response = self.client.post(nudge_url, query_params) + + # Verify the API response. + assert response.status_code == status.HTTP_200_OK + + mock_send_nudge_email.assert_called_once_with(self.assignment_accepted.uuid, 14) + + @mock.patch('enterprise_access.apps.content_assignments.api.get_content_metadata_for_assignments') + @mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') + def test_nudge_allocated_assignment(self, mock_send_nudge_email, mock_content_metadata_for_assignments): + """ + Test that the cancel view cancels the assignment and returns an appropriate response with 200 status code and + the expected results of serialization. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14) + end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) + enrollment_end = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + + # Mock content metadata for assignment + mock_content_metadata_for_assignments.return_value = { + 'edX+edXPrivacy101': { + '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-%d %H:%M"), + 'content_price': 321, + }, + 'course_type': 'executive-education-2u', + } + } + # Call the nudge endpoint. + nudge_kwargs = { + 'assignment_configuration_uuid': self.assignment_configuration.uuid, + } + nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs) + query_params = { + 'assignment_uuids': [str(self.assignment_allocated_post_link.uuid)], + 'days_before_course_start_date': 14 + } + + response = self.client.post(nudge_url, query_params) + + # Verify the API response. + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + mock_send_nudge_email.assert_not_called() + def test_bulk_cancel(self): """ Test that the cancel view cancels the assignment and returns an appropriate response diff --git a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py index e5c86117..870ffe5c 100644 --- a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py +++ b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py @@ -369,19 +369,26 @@ def nudge(self, request, *args, **kwargs): ``` Raises: - 400 If ``assignment_uuids`` list length is 0 or the value for ``days_before_course_start_date`` is less than 1 + 400 If assignment_uuids list length is 0 or the value for days_before_course_start_date is less than 1 422 If the nudge_assignments call fails for any other reason ``` """ serializer = LearnerContentAssignmentNudgeRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) + assignment_configuration_uuid = self.requested_assignment_configuration_uuid assignments = self.get_queryset().filter( - assignment_configuration__uuid=self.requested_assignment_configuration_uuid, + assignment_configuration__uuid=assignment_configuration_uuid, uuid__in=serializer.data['assignment_uuids'], ) days_before_course_start_date = serializer.data['days_before_course_start_date'] try: - response = assignments_api.nudge_assignments(assignments, days_before_course_start_date) - return Response(data=response, status=status.HTTP_200_OK) + result = assignments_api.nudge_assignments( + assignments, + assignment_configuration_uuid, + days_before_course_start_date + ) + response_serializer = LearnerContentAssignmentNudgeResponseSerializer(data=result) + response_serializer.is_valid(raise_exception=True) + return Response(data=response_serializer.data, status=status.HTTP_200_OK) except Exception: # pylint: disable=broad-except return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index f6359725..de8a2671 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -578,7 +578,7 @@ def remind_assignments(assignments: Iterable[LearnerContentAssignment]) -> dict: } -def nudge_assignments(assignments, days_before_course_start_date): +def nudge_assignments(assignments, assignment_configuration_uuid, days_before_course_start_date): """ Nudge assignments. @@ -587,85 +587,115 @@ def nudge_assignments(assignments, days_before_course_start_date): Args: - assignment: An assignment to nudge + assignments: An iterable of assignments associated to the payloads assignment_uuids and + associated assignment_configuration_uuid + assignment_configuration_uuid: Uuid of the assignment configuration from the api path days_before_course_start_date: Number of days prior to start date to nudge individual assignment - content_metadata: Content metadata of the assigned course to be nudged """ + + # Declare our expected response output nudged_assignment_uuids = [] unnudged_assignment_uuids = [] - + # Isolate assignment configuration metadata and associated assignments + assignment_configuration = AssignmentConfiguration.objects.get(uuid=assignment_configuration_uuid) + subsidy_access_policy = assignment_configuration.subsidy_access_policy + enterprise_catalog_uuid = subsidy_access_policy.catalog_uuid + # Check each assignment to validate its state and retreive its content metadata for assignment in assignments: - if assignment.state == LearnerContentAssignmentStateChoices.ACCEPTED: - assignment_configuration = AssignmentConfiguration.objects.filter(uuid=assignment.assignment_configuration) - subsidy_access_policy = assignment_configuration.subsidy_access_policy - enterprise_catalog_uuid = subsidy_access_policy.catalog_uuid + # Send a log and append to the unnudged_assignment_uuids response + # list assignments states that are not 'accepted' + # Then continue to the next assignment without sending a nudge email + if assignment.state != LearnerContentAssignmentStateChoices.ACCEPTED: + logger.info( + '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_ERROR_1] assignment: [%s]', + assignment + ) + unnudged_assignment_uuids.append(assignment.uuid) + continue + + # log metadata for observability relating to the assignment configuration + message = ( + '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_1] ' + 'Assignment Configuration uuid: [%s], assignmnet_uuid: [%s], ' + 'subsidy_access_policy_uuid: [%s], enterprise_catalog_uuid: [%s], ' + 'enterprise_customer_uuid: [%s] ' + ) + logger.info( + message, + assignment_configuration.uuid, + assignment.uuid, + subsidy_access_policy.uuid, + enterprise_catalog_uuid, + assignment_configuration.enterprise_customer_uuid, + ) + + # retrieve content_metadata for the assignment, and isolate the necessary fields + content_metadata_for_assignments = get_content_metadata_for_assignments( + enterprise_catalog_uuid, + [assignment], + ) + 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') + + # check if the course_type is an executive-education course + is_executive_education_course_type = course_type == 'executive-education-2u' + + # Determine if the date from today + days_before_course_state_date is + # equal to the date of the start date + # If they are equal, then send the nudge email, otherwise continue + datetime_start_date = parse_datetime_string(start_date, set_to_utc=True) + can_send_nudge_notification_in_advance = is_date_n_days_from_now( + target_datetime=datetime_start_date, + num_days=days_before_course_start_date + ) + # Determine if we can nudge a user, if we can nudge, log a message, send the nudge, + # and append to the nudged_assignment_uuids response list + # Otherwise, log a message, and append to the nudged_assignment_uuids response list + if is_executive_education_course_type and can_send_nudge_notification_in_advance: message = ( - '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_1] ' - 'Assignment Configuration. UUID: [%s], ' - 'Policy: [%s], Catalog: [%s], Enterprise: [%s]', + '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_2] assignment_configuration_uuid: [%s], ' + 'assignment_uuid: [%s], start_date: [%s], datetime_start_date: [%s], ' + 'days_before_course_start_date: [%s], can_send_nudge_notification_in_advance: [%s], ' + 'course_type: [%s], is_executive_education_course_type: [%s]' ) logger.info( message, - assignment_configuration.uuid, - subsidy_access_policy.uuid, - enterprise_catalog_uuid, - assignment_configuration.enterprise_customer_uuid, + assignment_configuration_uuid, + assignment.uuid, + start_date, + datetime_start_date, + days_before_course_start_date, + can_send_nudge_notification_in_advance, + course_type, + is_executive_education_course_type ) - - content_metadata_for_assignments = get_content_metadata_for_assignments( - enterprise_catalog_uuid, - [assignment], - ) - 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') - - is_executive_education_course_type = course_type == 'executive-education-2u' - - # Determine if the date from today + days_before_course_state_date is - # equal to the date of the start date - # If they are equal, then send the nudge email, otherwise continue - datetime_start_date = parse_datetime_string(start_date, set_to_utc=True) - can_send_nudge_notification_in_advance = is_date_n_days_from_now( - target_datetime=datetime_start_date, - num_days=days_before_course_start_date - ) - - if is_executive_education_course_type and can_send_nudge_notification_in_advance: - message = ( - '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_2] ', - 'assignment_configuration_uuid: [%s], start_date: [%s], datetime_start_date: [%s], ' - 'days_before_course_start_date: [%s], can_send_nudge_notification_in_advance: [%s], ' - 'course_type: [%s]' - ) - logger.info( - message, - assignment.assignment_configuration, - start_date, - datetime_start_date, - days_before_course_start_date, - can_send_nudge_notification_in_advance, - course_type, - ) - send_exec_ed_enrollment_warmer.delay(assignment.uuid, days_before_course_start_date) - nudged_assignment_uuids.append(assignment.uuid) - else: - unnudged_assignment_uuids.append(assignment.uuid) + send_exec_ed_enrollment_warmer.delay(assignment.uuid, days_before_course_start_date) + nudged_assignment_uuids.append(assignment.uuid) else: message = ( - '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_ERROR] ' - 'assignment: [%s], ' - 'days_before_course_start_date: [%s]' + '[API_BRAZE_EMAIL_CAMPAIGN_NUDGING_ERROR_2] assignment_configuration_uuid: [%s], ' + 'assignment_uuid: [%s], start_date: [%s], datetime_start_date: [%s], ' + 'days_before_course_start_date: [%s], can_send_nudge_notification_in_advance: [%s], ' + 'course_type: [%s], is_executive_education_course_type: [%s]' ) logger.info( message, - assignment, days_before_course_start_date + assignment_configuration_uuid, + assignment.uuid, + start_date, + datetime_start_date, + days_before_course_start_date, + can_send_nudge_notification_in_advance, + course_type, + is_executive_education_course_type ) unnudged_assignment_uuids.append(assignment.uuid) + # returns the lists as an object to the response return { 'nudged_assignment_uuids': nudged_assignment_uuids, - 'unnudged_assignment_uuids': unnudged_assignment_uuids, + 'unnudged_assignment_uuids': unnudged_assignment_uuids } diff --git a/enterprise_access/apps/content_assignments/content_metadata_api.py b/enterprise_access/apps/content_assignments/content_metadata_api.py index d434cb53..e4440411 100644 --- a/enterprise_access/apps/content_assignments/content_metadata_api.py +++ b/enterprise_access/apps/content_assignments/content_metadata_api.py @@ -106,10 +106,16 @@ def get_course_partners(course_metadata): def is_date_n_days_from_now(target_datetime, num_days): """ - Takes an integer number of days to offset from the date_to_offset to determine if - the target_date matches the date_to_offset + days_offset date + Determine if the target_datetime is exactly num_days from the current + UTC date and time. - The target_date and date_to_offset arguments are UTC timezone objects + Args: + target_datetime (datetime): A datetime object in UTC that is to be compared. + num_days (int): The number of days from the current date to check against the + target datetime + + Returns: + bool: True if target_datetime is num_days away from now, otherwise False. """ future_datetime = timezone.now() + timezone.timedelta(days=num_days) return target_datetime.date() == future_datetime.date() 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 index 4e10c639..9cd3d1d6 100644 --- a/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py +++ b/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py @@ -77,6 +77,7 @@ def handle(self, *args, **options): '[AUTOMATICALLY_REMIND_ACCEPTED_ASSIGNMENTS_1] Assignment Configuration. UUID: [%s], ' 'Policy: [%s], Catalog: [%s], Enterprise: [%s], dry_run [%s]', ) + logger.info( message, assignment_configuration.uuid, From 937d0121b2f2e64c79135d217461b0efc30ffbc4 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 30 Jan 2024 22:33:35 +0000 Subject: [PATCH 5/5] chore: Additional logging and exception handling --- .../content_assignments/assignment.py | 22 ++++-- .../api/v1/tests/test_assignment_views.py | 72 +++++++++++++------ .../content_assignments/assignments_admin.py | 20 +++++- .../apps/content_assignments/api.py | 1 + .../content_metadata_api.py | 2 +- .../apps/content_assignments/tasks.py | 5 ++ 6 files changed, 95 insertions(+), 27 deletions(-) diff --git a/enterprise_access/apps/api/serializers/content_assignments/assignment.py b/enterprise_access/apps/api/serializers/content_assignments/assignment.py index 883cda35..a714c801 100644 --- a/enterprise_access/apps/api/serializers/content_assignments/assignment.py +++ b/enterprise_access/apps/api/serializers/content_assignments/assignment.py @@ -21,6 +21,7 @@ class LearnerContentAssignmentActionSerializer(serializers.ModelSerializer): """ A read-only Serializer for responding to requests for ``LearnerContentAssignmentAction`` records. """ + class Meta: model = LearnerContentAssignmentAction fields = [ @@ -181,10 +182,12 @@ class LearnerContentAssignmentNudgeRequestSerializer(serializers.Serializer): """ assignment_uuids = serializers.ListField( child=serializers.UUIDField(), - allow_empty=False + allow_empty=False, + help_text="A list of executive education assignment uuids associated with an assignment configuration" ) days_before_course_start_date = serializers.IntegerField( - min_value=1 + min_value=1, + help_text="The number days ahead of a course start_date we want to send a nudge email for" ) @@ -196,14 +199,25 @@ class LearnerContentAssignmentNudgeResponseSerializer(serializers.Serializer): """ nudged_assignment_uuids = serializers.ListField( child=serializers.UUIDField(), - allow_empty=False + allow_empty=False, + help_text="A list of uuids that have been sent to the celery task to nudge" ) unnudged_assignment_uuids = serializers.ListField( child=serializers.UUIDField(), - allow_empty=True + allow_empty=True, + help_text="A list of uuids that have not been sent to the celery task to nudge" ) +class LearnerContentAssignmentNudgeHTTP422ErrorSerializer(serializers.Serializer): + """ + Response serializer for nudge endpoint 422 errors. + + For view: LearnerContentAssignmentAdminViewSet.nudge + """ + error_message = serializers.CharField() + + class ContentMetadataForAssignmentSerializer(serializers.Serializer): """ Serializer to help return additional content metadata for assignments. These fields should diff --git a/enterprise_access/apps/api/v1/tests/test_assignment_views.py b/enterprise_access/apps/api/v1/tests/test_assignment_views.py index e66b1b18..0d0ded56 100644 --- a/enterprise_access/apps/api/v1/tests/test_assignment_views.py +++ b/enterprise_access/apps/api/v1/tests/test_assignment_views.py @@ -647,7 +647,7 @@ def test_cancel(self, mock_send_cancel_email): @mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for_assignments): """ - Test that the cancel view cancels the assignment and returns an appropriate response with 200 status code and + Test that the nudge view nudges the assignment and returns an appropriate response with 200 status code and the expected results of serialization. """ # Set the JWT-based auth to an operator. @@ -682,18 +682,24 @@ def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for 'days_before_course_start_date': 14 } + expected_response = { + "nudged_assignment_uuids": [str(self.assignment_accepted.uuid)], + "unnudged_assignment_uuids": [] + } + response = self.client.post(nudge_url, query_params) # Verify the API response. assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response mock_send_nudge_email.assert_called_once_with(self.assignment_accepted.uuid, 14) - @mock.patch('enterprise_access.apps.content_assignments.api.get_content_metadata_for_assignments') @mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') - def test_nudge_allocated_assignment(self, mock_send_nudge_email, mock_content_metadata_for_assignments): + def test_nudge_allocated_assignment(self, mock_send_nudge_email): """ - Test that the cancel view cancels the assignment and returns an appropriate response with 200 status code and + Test that the nudge view doesn't nudge the assignment and + returns an appropriate response with 422 status code and the expected results of serialization. """ # Set the JWT-based auth to an operator. @@ -701,37 +707,63 @@ def test_nudge_allocated_assignment(self, mock_send_nudge_email, mock_content_me {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} ]) - start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14) - end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180) - enrollment_end = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5) + # Call the nudge endpoint. + nudge_kwargs = { + 'assignment_configuration_uuid': self.assignment_configuration.uuid, + } + nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs) + query_params = { + 'assignment_uuids': [str(self.assignment_allocated_post_link.uuid)], + 'days_before_course_start_date': 14 + } - # Mock content metadata for assignment - mock_content_metadata_for_assignments.return_value = { - 'edX+edXPrivacy101': { - '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-%d %H:%M"), - 'content_price': 321, - }, - 'course_type': 'executive-education-2u', - } + response = self.client.post(nudge_url, query_params) + + expected_response = { + "error_message": "Could not process the nudge email(s) for assignment_configuration_uuid: {0}" + .format(self.assignment_configuration.uuid), } + + # Verify the API response. + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response + + mock_send_nudge_email.assert_not_called() + + @mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay') + def test_nudge_no_assignments(self, mock_send_nudge_email): + """ + Test that the nudge view doesn't nudge the assignment and + returns an appropriate response with 422 status code and + the expected results of serialization. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + # Call the nudge endpoint. nudge_kwargs = { 'assignment_configuration_uuid': self.assignment_configuration.uuid, } nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs) + query_params = { - 'assignment_uuids': [str(self.assignment_allocated_post_link.uuid)], + 'assignment_uuids': [str(uuid4())], 'days_before_course_start_date': 14 } response = self.client.post(nudge_url, query_params) + expected_response = { + "error_message": "The list of assignments provided are not " + "associated to the assignment_configuration_uuid: {0}" + .format(self.assignment_configuration.uuid) + } + # Verify the API response. assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response mock_send_nudge_email.assert_not_called() diff --git a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py index 870ffe5c..28f7f6a1 100644 --- a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py +++ b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py @@ -15,6 +15,7 @@ from enterprise_access.apps.api import filters, serializers, utils from enterprise_access.apps.api.serializers.content_assignments.assignment import ( LearnerContentAssignmentActionRequestSerializer, + LearnerContentAssignmentNudgeHTTP422ErrorSerializer, LearnerContentAssignmentNudgeRequestSerializer, LearnerContentAssignmentNudgeResponseSerializer ) @@ -357,7 +358,7 @@ def remind_all(self, request, *args, **kwargs): parameters=None, responses={ status.HTTP_200_OK: LearnerContentAssignmentNudgeResponseSerializer, - status.HTTP_422_UNPROCESSABLE_ENTITY: None, + status.HTTP_422_UNPROCESSABLE_ENTITY: LearnerContentAssignmentNudgeHTTP422ErrorSerializer, } ) @permission_required(CONTENT_ASSIGNMENT_ADMIN_WRITE_PERMISSION, fn=assignment_admin_permission_fn) @@ -382,6 +383,14 @@ def nudge(self, request, *args, **kwargs): ) days_before_course_start_date = serializer.data['days_before_course_start_date'] try: + if len(assignments) == 0: + error_message = ( + "The list of assignments provided are not associated to the assignment_configuration_uuid: {0}" + .format(assignment_configuration_uuid) + ) + return Response( + data={"error_message": error_message}, status=status.HTTP_422_UNPROCESSABLE_ENTITY + ) result = assignments_api.nudge_assignments( assignments, assignment_configuration_uuid, @@ -391,4 +400,11 @@ def nudge(self, request, *args, **kwargs): response_serializer.is_valid(raise_exception=True) return Response(data=response_serializer.data, status=status.HTTP_200_OK) except Exception: # pylint: disable=broad-except - return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + error_message = ( + "Could not process the nudge email(s) for assignment_configuration_uuid: {0}" + .format(assignment_configuration_uuid) + ) + return Response( + data={"error_message": error_message}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY + ) diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index de8a2671..5eb9637a 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -596,6 +596,7 @@ def nudge_assignments(assignments, assignment_configuration_uuid, days_before_co # Declare our expected response output nudged_assignment_uuids = [] unnudged_assignment_uuids = [] + # Isolate assignment configuration metadata and associated assignments assignment_configuration = AssignmentConfiguration.objects.get(uuid=assignment_configuration_uuid) subsidy_access_policy = assignment_configuration.subsidy_access_policy diff --git a/enterprise_access/apps/content_assignments/content_metadata_api.py b/enterprise_access/apps/content_assignments/content_metadata_api.py index e4440411..f7cb1e1d 100644 --- a/enterprise_access/apps/content_assignments/content_metadata_api.py +++ b/enterprise_access/apps/content_assignments/content_metadata_api.py @@ -62,7 +62,7 @@ def get_human_readable_date(datetime_string, output_pattern=DEFAULT_STRFTIME_PAT return None -def parse_datetime_string(datetime_string, **set_to_utc): +def parse_datetime_string(datetime_string, set_to_utc=False): """ Given a datetime string value from some content metadata record, parse it into a datetime object. diff --git a/enterprise_access/apps/content_assignments/tasks.py b/enterprise_access/apps/content_assignments/tasks.py index 45f6a92b..d0a6497b 100644 --- a/enterprise_access/apps/content_assignments/tasks.py +++ b/enterprise_access/apps/content_assignments/tasks.py @@ -401,6 +401,11 @@ def send_exec_ed_enrollment_warmer(assignment_uuid, days_before_course_start_dat campaign_uuid = settings.BRAZE_ASSIGNMENT_NUDGE_EXEC_ED_ACCEPTED_ASSIGNMENT_CAMPAIGN + logger.info( + f'Sending braze campaign nudge reminder at ' + f'days_before_course_start_date={days_before_course_start_date} ' + f'uuid={campaign_uuid} message for assignment {assignment}' + ) campaign_sender.send_campaign_message( braze_trigger_properties, campaign_uuid,