diff --git a/enterprise_access/apps/api/serializers/content_assignments/assignment.py b/enterprise_access/apps/api/serializers/content_assignments/assignment.py index 006a82e7..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 = [ @@ -173,6 +174,50 @@ 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, + help_text="A list of executive education assignment uuids associated with an assignment configuration" + ) + days_before_course_start_date = serializers.IntegerField( + min_value=1, + help_text="The number days ahead of a course start_date we want to send a nudge email for" + ) + + +class LearnerContentAssignmentNudgeResponseSerializer(serializers.Serializer): + """ + Response serializer for nudge endpoint. + + For view: LearnerContentAssignmentAdminViewSet.nudge + """ + nudged_assignment_uuids = serializers.ListField( + child=serializers.UUIDField(), + 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, + 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 a8b6d777..0d0ded56 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,130 @@ 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 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. + 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 + } + + 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.tasks.send_exec_ed_enrollment_warmer.delay') + def test_nudge_allocated_assignment(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)], + 'days_before_course_start_date': 14 + } + + 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(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() + 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 0e6c798b..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 @@ -14,7 +14,10 @@ from enterprise_access.apps.api import filters, serializers, utils from enterprise_access.apps.api.serializers.content_assignments.assignment import ( - LearnerContentAssignmentActionRequestSerializer + LearnerContentAssignmentActionRequestSerializer, + LearnerContentAssignmentNudgeHTTP422ErrorSerializer, + 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 +350,61 @@ 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: LearnerContentAssignmentNudgeHTTP422ErrorSerializer, + } + ) + @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) + assignment_configuration_uuid = self.requested_assignment_configuration_uuid + assignments = self.get_queryset().filter( + 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: + 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, + 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 + 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 6f527add..5eb9637a 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,128 @@ def remind_assignments(assignments: Iterable[LearnerContentAssignment]) -> dict: } +def nudge_assignments(assignments, assignment_configuration_uuid, 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: + 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 + """ + + # 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: + # 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_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, + 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 + ) + 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_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, + 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 + } + + def expire_assignment(assignment, content_metadata, modify_assignment=True): """ If applicable, retires the given assignment, returning an expiration reason. @@ -604,7 +734,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: @@ -622,7 +752,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/content_metadata_api.py b/enterprise_access/apps/content_assignments/content_metadata_api.py index 0a8fd1b4..f7cb1e1d 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=False): """ 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,20 @@ 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): + """ + Determine if the target_datetime is exactly num_days from the current + UTC date and time. + + 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 new file mode 100644 index 00000000..9cd3d1d6 --- /dev/null +++ b/enterprise_access/apps/content_assignments/management/commands/automatically_nudge_assignments.py @@ -0,0 +1,137 @@ +""" +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 enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices +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.tasks import send_exec_ed_enrollment_warmer + +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 == '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 = ( + '[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_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 new file mode 100644 index 00000000..9b8721cd --- /dev/null +++ b/enterprise_access/apps/content_assignments/management/commands/tests/test_automatically_nudge_assignments.py @@ -0,0 +1,569 @@ +""" +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.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, + 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().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), + '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.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_exec_ed_enrollment_warmer_task, + mock_content_metadata_for_assignments, + ): + """ + Verify that management command work as expected. + """ + 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), + '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, days_before_course_start_date=14) + + 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.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_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().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) + 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_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.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_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().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), + '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_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.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_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().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 + + 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_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..d0a6497b 100644 --- a/enterprise_access/apps/content_assignments/tasks.py +++ b/enterprise_access/apps/content_assignments/tasks.py @@ -366,6 +366,57 @@ 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 + + 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, + ) + 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/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 = ''