diff --git a/enterprise_access/apps/api/serializers/content_assignments/assignment.py b/enterprise_access/apps/api/serializers/content_assignments/assignment.py index 9711bd69..a284c146 100644 --- a/enterprise_access/apps/api/serializers/content_assignments/assignment.py +++ b/enterprise_access/apps/api/serializers/content_assignments/assignment.py @@ -13,7 +13,7 @@ LearnerContentAssignmentStateChoices ) from enterprise_access.apps.content_assignments.models import LearnerContentAssignment, LearnerContentAssignmentAction -from enterprise_access.utils import get_automatic_expiration_date_and_reason +from enterprise_access.utils import get_automatic_expiration_date_and_reason, get_normalized_metadata_for_assignment logger = logging.getLogger(__name__) @@ -145,8 +145,8 @@ def get_earliest_possible_expiration(self, assignment): """ Returns the earliest possible expiration date for the assignment. """ - assignment_content_metadata = self.get_content_metadata_from_context(assignment.content_key) - return get_automatic_expiration_date_and_reason(assignment, content_metadata=assignment_content_metadata) + content_metadata = self.get_content_metadata_from_context(assignment.content_key) + return get_automatic_expiration_date_and_reason(assignment, content_metadata) class LearnerContentAssignmentAdminResponseSerializer(LearnerContentAssignmentResponseSerializer): @@ -290,7 +290,7 @@ class ContentMetadataForAssignmentSerializer(serializers.Serializer): content_price = serializers.SerializerMethodField( help_text='The price, in USD, of this content', ) - course_type = serializers.CharField( + course_type = serializers.SerializerMethodField( help_text='The type of course, something like "executive-education-2u" or "verified-audit"', # Try to be a little defensive against malformed data. required=False, @@ -298,21 +298,37 @@ class ContentMetadataForAssignmentSerializer(serializers.Serializer): ) partners = serializers.SerializerMethodField() + def _assignment(self, obj): + return obj.get('assignment') + + def _content_metadata(self, obj): + return obj.get('content_metadata') + + def _normalized_metadata(self, obj): + return get_normalized_metadata_for_assignment(self._assignment(obj), self._content_metadata(obj)) + @extend_schema_field(serializers.DateTimeField) def get_start_date(self, obj): - return obj.get('normalized_metadata', {}).get('start_date') + return self._normalized_metadata(obj).get('start_date') @extend_schema_field(serializers.DateTimeField) def get_end_date(self, obj): - return obj.get('normalized_metadata', {}).get('end_date') + return self._normalized_metadata(obj).get('end_date') @extend_schema_field(serializers.DateTimeField) def get_enroll_by_date(self, obj): - return obj.get('normalized_metadata', {}).get('enroll_by_date') + return self._normalized_metadata(obj).get('enroll_by_date') @extend_schema_field(serializers.IntegerField) def get_content_price(self, obj): - return obj.get('normalized_metadata', {}).get('content_price') + return self._normalized_metadata(obj).get('content_price') + + @extend_schema_field(serializers.CharField) + def get_course_type(self, obj): + """ + Returns the course type for the content metadata, if available. + """ + return self._content_metadata(obj).get('course_type') @extend_schema_field(CoursePartnerSerializer) def get_partners(self, obj): @@ -321,7 +337,7 @@ def get_partners(self, obj): enterprise-catalog/enterprise_catalog/apps/catalog/algolia_utils.py """ partners = [] - owners = obj.get('owners') or [] + owners = self._content_metadata(obj).get('owners') or [] for owner in owners: partner_name = owner.get('name') @@ -353,10 +369,10 @@ def get_content_metadata(self, obj): """ Serializers content metadata for the assignment, if available. """ - assignment_content_metadata = self.get_content_metadata_from_context(obj.content_key) - if not assignment_content_metadata: + content_metadata = self.get_content_metadata_from_context(obj.content_key) + if not content_metadata: return None - return ContentMetadataForAssignmentSerializer(assignment_content_metadata).data + return ContentMetadataForAssignmentSerializer({'assignment': obj, 'content_metadata': content_metadata}).data class LearnerContentAssignmentWithLearnerAcknowledgedResponseSerializer( diff --git a/enterprise_access/apps/api/v1/tests/test_allocation_view.py b/enterprise_access/apps/api/v1/tests/test_allocation_view.py index 75c8badd..d7c87b69 100644 --- a/enterprise_access/apps/api/v1/tests/test_allocation_view.py +++ b/enterprise_access/apps/api/v1/tests/test_allocation_view.py @@ -66,6 +66,7 @@ def setUpTestData(cls): super().setUpTestData() cls.enterprise_uuid = TEST_ENTERPRISE_UUID cls.content_key = 'course-v1:edX+edXPrivacy101+3T2020' + cls.parent_content_key = 'edX+edXPrivacy101' cls.content_title = 'edx: Privacy 101' # Create a pair of AssignmentConfiguration + SubsidyAccessPolicy for the main test customer. @@ -85,6 +86,8 @@ def setUpTestData(cls): learner_email='alice@foo.com', lms_user_id=None, content_key=cls.content_key, + parent_content_key=cls.parent_content_key, + is_assigned_course_run=True, content_title=cls.content_title, content_quantity=-123, state=LearnerContentAssignmentStateChoices.ERRORED, @@ -94,6 +97,8 @@ def setUpTestData(cls): learner_email='bob@foo.com', lms_user_id=None, content_key=cls.content_key, + parent_content_key=cls.parent_content_key, + is_assigned_course_run=True, content_title=cls.content_title, content_quantity=-456, state=LearnerContentAssignmentStateChoices.ALLOCATED, @@ -103,6 +108,8 @@ def setUpTestData(cls): learner_email='carol@foo.com', lms_user_id=None, content_key=cls.content_key, + parent_content_key=cls.parent_content_key, + is_assigned_course_run=True, content_title=cls.content_title, content_quantity=-789, state=LearnerContentAssignmentStateChoices.ALLOCATED, @@ -221,7 +228,6 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs } response = self.client.post(allocate_url, data=allocate_payload) - self.assertEqual(status.HTTP_202_ACCEPTED, response.status_code) expected_response_payload = { 'updated': [ @@ -230,8 +236,8 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs 'learner_email': 'alice@foo.com', 'lms_user_id': None, 'content_key': self.content_key, - 'parent_content_key': None, - 'is_assigned_course_run': False, + 'parent_content_key': self.parent_content_key, + 'is_assigned_course_run': True, 'content_title': self.content_title, 'content_quantity': -123, 'state': LearnerContentAssignmentStateChoices.ERRORED, @@ -252,8 +258,8 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs 'learner_email': 'bob@foo.com', 'lms_user_id': None, 'content_key': self.content_key, - 'parent_content_key': None, - 'is_assigned_course_run': False, + 'parent_content_key': self.parent_content_key, + 'is_assigned_course_run': True, 'content_title': self.content_title, 'content_quantity': -456, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, @@ -274,8 +280,8 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs 'learner_email': 'carol@foo.com', 'lms_user_id': None, 'content_key': self.content_key, - 'parent_content_key': None, - 'is_assigned_course_run': False, + 'parent_content_key': self.parent_content_key, + 'is_assigned_course_run': True, 'content_title': self.content_title, 'content_quantity': -789, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, 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 b07a9810..f57e8f8d 100644 --- a/enterprise_access/apps/api/v1/tests/test_assignment_views.py +++ b/enterprise_access/apps/api/v1/tests/test_assignment_views.py @@ -823,6 +823,14 @@ def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for 'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"), 'content_price': self.content_metadata_one['content_quantity'], }, + 'normalized_metadata_by_run': { + self.content_metadata_one['content_key']: { + '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': self.content_metadata_one['content_quantity'], + }, + }, 'course_type': 'executive-education-2u', }, } diff --git a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py index 6e2ee41f..bae397cc 100755 --- a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py +++ b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py @@ -1559,7 +1559,6 @@ def test_credits_available_endpoint_with_content_assignments( Verify that SubsidyAccessPolicyViewset credits_available returns learner content assignments for assigned learner credit access policies. """ - self.maxDiff = None parent_content_key = 'edX+DemoX' content_key = 'course-v1:edX+DemoX+T2024a' content_title = 'edx: Demo 101' @@ -1621,13 +1620,21 @@ def test_credits_available_endpoint_with_content_assignments( # Mock catalog content metadata results. See LearnerContentAssignmentWithContentMetadataResponseSerializer # for what we expect to be in the response payload w.r.t. content metadata. mock_content_metadata = { - 'key': content_key, + 'key': parent_content_key, 'normalized_metadata': { 'start_date': '2020-01-01T12:00:00Z', 'end_date': '2022-01-01T12:00:00Z', 'enroll_by_date': '2021-01-01T12:00:00Z', 'content_price': content_price_cents, }, + 'normalized_metadata_by_run': { + content_key: { + 'start_date': '2020-01-01T12:00:00Z', + 'end_date': '2022-01-01T12:00:00Z', + 'enroll_by_date': '2021-01-01T12:00:00Z', + 'content_price': content_price_cents, + }, + }, 'course_type': 'verified-audit', 'owners': [ {'name': 'Smart Folks', 'logo_image_url': 'http://pictures.yes'}, diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index 0de0dfc8..681fe4a8 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -23,7 +23,12 @@ ) from enterprise_access.apps.core.models import User from enterprise_access.apps.subsidy_access_policy.content_metadata_api import get_and_cache_content_metadata -from enterprise_access.utils import chunks, get_automatic_expiration_date_and_reason, localized_utcnow +from enterprise_access.utils import ( + chunks, + get_automatic_expiration_date_and_reason, + get_normalized_metadata_for_assignment, + localized_utcnow +) from .constants import AssignmentAutomaticExpiredReason, LearnerContentAssignmentStateChoices from .models import AssignmentConfiguration, LearnerContentAssignment @@ -761,8 +766,13 @@ def nudge_assignments(assignments, assignment_configuration_uuid, days_before_co enterprise_catalog_uuid, [assignment], ) + print('content_metadata_for_assignments?!?!', content_metadata_for_assignments) content_metadata = content_metadata_for_assignments.get(assignment.content_key, {}) - start_date = content_metadata.get('normalized_metadata', {}).get('start_date') + print('content_metadata?!?!', content_metadata) + normalized_metadata = get_normalized_metadata_for_assignment(assignment, content_metadata) + print('normalized_metadata?!?!', normalized_metadata) + + start_date = normalized_metadata.get('start_date') course_type = content_metadata.get('course_type') # check if the course_type is an executive-education course diff --git a/enterprise_access/apps/content_assignments/content_metadata_api.py b/enterprise_access/apps/content_assignments/content_metadata_api.py index f7cb1e1d..a10e01f7 100644 --- a/enterprise_access/apps/content_assignments/content_metadata_api.py +++ b/enterprise_access/apps/content_assignments/content_metadata_api.py @@ -28,11 +28,28 @@ def get_content_metadata_for_assignments(enterprise_catalog_uuid, assignments): to a content metadata dictionary, or null if no such dictionary could be found for a given key. """ - content_keys = sorted({assignment.content_key for assignment in assignments}) - content_metadata_list = get_and_cache_catalog_content_metadata(enterprise_catalog_uuid, content_keys) - metadata_by_key = { - record['key']: record for record in content_metadata_list + content_keys = { + (assignment.content_key, assignment.is_assigned_course_run) + for assignment in assignments } + content_metadata_list = get_and_cache_catalog_content_metadata(enterprise_catalog_uuid, content_keys) + metadata_by_key = {} + for record in content_metadata_list: + record_key = record.get('key') + + # Now, check if the record_key matches either the content_key or parent_content_key in the original content_keys + for content_key, is_assigned_course_run in content_keys: + if is_assigned_course_run: + metadata_by_key.update({ + content_key: record + }) + break # Stop searching after the match is found + if record_key == content_key: + metadata_by_key.update({ + record_key: record + }) + break # Stop searching after the match is found + return { assignment.content_key: metadata_by_key.get(assignment.content_key) for assignment in assignments 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 f17a5ddb..7d022e99 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 @@ -22,6 +22,7 @@ ) from enterprise_access.apps.content_assignments.models import AssignmentConfiguration from enterprise_access.apps.content_assignments.tasks import send_exec_ed_enrollment_warmer +from enterprise_access.utils import get_normalized_metadata_for_assignment logger = logging.getLogger(__name__) @@ -155,23 +156,20 @@ def handle(self, *args, **options): ) continue - # Nudge learners based on the start date of the "preferred" course run, NOT the start date from the - # "normalized metadata" derived from the *advertised* course run. That latter assumption caused us - # problems in the past because this script would just follow every new published run and keep - # re-triggering nudge emails. - course_run_metadata = next( - run for run in content_metadata['course_runs'] - if run['key'] == assignment.preferred_course_run_key - ) - start_date = course_run_metadata.get('start') + normalized_metadata = get_normalized_metadata_for_assignment(assignment, content_metadata) + start_date = normalized_metadata.get('start_date') # 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 + 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 start_date is not None + else False ) if not can_send_nudge_notification_in_advance: logger.info( 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 f161d9af..00e69f50 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 @@ -219,62 +219,74 @@ def test_command( mock_content_metadata_for_assignments.return_value = { 'edX+edXAccessibility101': { 'key': 'edX+edXAccessibility101', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXAccessibility101+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXAccessibility101+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), }, - ], + }, 'course_type': 'executive-education-2u', }, 'edX+edXPrivacy101': { 'key': 'edX+edXPrivacy101', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXPrivacy101+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXPrivacy101+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), }, - ], + }, 'course_type': 'executive-education-2u', }, 'edX+edXTesseract4D': { 'key': 'edX+edXTesseract4D', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXTesseract4D+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXTesseract4D+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), }, - ], + }, 'course_type': 'executive-education-2u', }, 'edX+edXQuadrilateral306090': { 'key': 'edX+edXQuadrilateral306090', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXQuadrilateral306090+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXQuadrilateral306090+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), }, - ], + }, 'course_type': 'executive-education-2u', }, 'edX+edXIsoscelesPyramid2012': { 'key': 'edX+edXIsoscelesPyramid2012', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXIsoscelesPyramid2012+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXIsoscelesPyramid2012+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), }, - ], + }, 'course_type': 'executive-education-2u', }, 'edX+edXBeeHivesAlive0220': { 'key': 'edX+edXBeeHivesAlive0220', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXBeeHivesAlive0220+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXBeeHivesAlive0220+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), }, - ], + }, 'course_type': 'executive-education-2u', }, } @@ -321,32 +333,38 @@ def test_command_multiple_assignment_dates( mock_content_metadata_for_assignments.return_value = { 'edX+edXAccessibility101': { 'key': 'edX+edXAccessibility101', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXAccessibility101+1T2022', - 'start': start_date_between_30_and_14_days.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date_between_30_and_14_days.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXAccessibility101+1T2022': { + 'start_date': start_date_between_30_and_14_days.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXTesseract4D': { 'key': 'edX+edXTesseract4D', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXTesseract4D+1T2022', - 'start': start_date_already_started.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date_already_started.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXTesseract4D+1T2022': { + 'start_date': start_date_already_started.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXQuadrilateral306090': { 'key': 'edX+edXQuadrilateral306090', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXQuadrilateral306090+1T2022', - 'start': start_date_beyond_30_days.strftime("%Y-%m-%dT%H:%M:%SZ"), + 'normalized_metadata': { + 'start_date': start_date_beyond_30_days.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXQuadrilateral306090+1T2022': { + 'start_date': start_date_beyond_30_days.strftime("%Y-%m-%dT%H:%M:%SZ"), }, - ], + }, 'course_type': 'executive-education-2u', }, 'edX+edXPrivacy101': { @@ -358,16 +376,26 @@ def test_command_multiple_assignment_dates( }, ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXPrivacy101+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXIsoscelesPyramid2012': { 'key': 'edX+edXIsoscelesPyramid2012', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXIsoscelesPyramid2012+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXIsoscelesPyramid2012+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXBeeHivesAlive0220': { 'key': 'edX+edXBeeHivesAlive0220', @@ -378,6 +406,14 @@ def test_command_multiple_assignment_dates( }, ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXBeeHivesAlive0220+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, } @@ -412,63 +448,75 @@ def test_command_multiple_assignment_course_types( mock_content_metadata_for_assignments.return_value = { 'edX+edXAccessibility101': { 'key': 'edX+edXAccessibility101', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXAccessibility101+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'verified-audit', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXAccessibility101+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXPrivacy101': { 'key': 'edX+edXPrivacy101', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXPrivacy101+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXPrivacy101+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXTesseract4D': { 'key': 'edX+edXTesseract4D', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXTesseract4D+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'professional', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXTesseract4D+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXQuadrilateral306090': { 'key': 'edX+edXQuadrilateral306090', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXQuadrilateral306090+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'bootcamp-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXQuadrilateral306090+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXIsoscelesPyramid2012': { 'key': 'edX+edXIsoscelesPyramid2012', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXIsoscelesPyramid2012+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXIsoscelesPyramid2012+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXBeeHivesAlive0220': { 'key': 'edX+edXBeeHivesAlive0220', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXBeeHivesAlive0220+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXBeeHivesAlive0220+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, } @@ -505,63 +553,75 @@ def test_command_multiple_assignment_states( mock_content_metadata_for_assignments.return_value = { 'edX+edXAccessibility101': { 'key': 'edX+edXAccessibility101', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXAccessibility101+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXAccessibility101+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXTesseract4D': { 'key': 'edX+edXTesseract4D', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXTesseract4D+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXTesseract4D+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXQuadrilateral306090': { 'key': 'edX+edXQuadrilateral306090', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXQuadrilateral306090+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXQuadrilateral306090+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXPrivacy101': { 'key': 'edX+edXPrivacy101', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXPrivacy101+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXPrivacy101+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXIsoscelesPyramid2012': { 'key': 'edX+edXIsoscelesPyramid2012', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXIsoscelesPyramid2012+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXIsoscelesPyramid2012+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, 'edX+edXBeeHivesAlive0220': { 'key': 'edX+edXBeeHivesAlive0220', - 'course_runs': [ - { - 'key': 'course-v1:edX+edXBeeHivesAlive0220+1T2022', - 'start': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ], 'course_type': 'executive-education-2u', + 'normalized_metadata': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + 'course-v1:edX+edXBeeHivesAlive0220+1T2022': { + 'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, } @@ -688,6 +748,7 @@ def test_command( learner_email='alice@foo.com', lms_user_id=None, content_key=self.COURSE_KEY, + is_assigned_course_run=False, preferred_course_run_key=assignment_preferred_course_run_key, content_title='edx: Privacy 101', content_quantity=-123, @@ -700,11 +761,15 @@ def test_command( mock_content_metadata_for_assignments.return_value = { self.COURSE_KEY: { 'key': self.COURSE_KEY, - 'course_runs': [{ - 'key': self.COURSE_RUN_KEY, - 'start': course_start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - }], 'course_type': course_type, + 'normalized_metadata': { + 'start_date': course_start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + 'normalized_metadata_by_run': { + self.COURSE_RUN_KEY: { + 'start_date': course_start_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, }, } call_command(self.command, days_before_course_start_date=14) diff --git a/enterprise_access/apps/content_assignments/tasks.py b/enterprise_access/apps/content_assignments/tasks.py index 2e2a1bff..736871da 100644 --- a/enterprise_access/apps/content_assignments/tasks.py +++ b/enterprise_access/apps/content_assignments/tasks.py @@ -19,7 +19,11 @@ get_human_readable_date ) from enterprise_access.tasks import LoggedTaskWithRetry -from enterprise_access.utils import get_automatic_expiration_date_and_reason, localized_utcnow +from enterprise_access.utils import ( + get_automatic_expiration_date_and_reason, + get_normalized_metadata_for_assignment, + localized_utcnow +) from .constants import BRAZE_ACTION_REQUIRED_BY_TIMESTAMP_FORMAT, LearnerContentAssignmentStateChoices @@ -152,11 +156,19 @@ def course_metadata(self): if not self._course_metadata: msg = ( f'Could not fetch metadata for assignment {self.assignment.uuid}, ' - f'content_key {self.assignment.content_key}' + f'content_key {self.assignment.content_key}, ' + f'parent_content_key {self.assignment.parent_content_key}' ) raise Exception(msg) return self._course_metadata + @property + def normalized_metadata(self): + """ + TODO + """ + return get_normalized_metadata_for_assignment(self.assignment, self.course_metadata) + @property def subsidy_record(self): """ @@ -193,14 +205,14 @@ def get_course_title(self): return self.assignment.content_title def _enrollment_deadline_raw(self): - return self.course_metadata.get('normalized_metadata', {}).get('enroll_by_date') + return self.normalized_metadata.get('enroll_by_date') def get_enrollment_deadline(self): return get_human_readable_date(self._enrollment_deadline_raw()) def get_start_date(self): return get_human_readable_date( - self.course_metadata.get('normalized_metadata', {}).get('start_date') + self.normalized_metadata.get('start_date') ) def get_action_required_by_timestamp(self): @@ -208,7 +220,7 @@ def get_action_required_by_timestamp(self): Returns the minimum of this assignment's auto-expiration date, the content's enrollment deadline, and the related policy's expiration timestamp. """ - action_required_by_timestamp = get_automatic_expiration_date_and_reason(self.assignment) + action_required_by_timestamp = get_automatic_expiration_date_and_reason(self.assignment, self.course_metadata) if not action_required_by_timestamp: return None return format_datetime_obj( @@ -228,7 +240,7 @@ def get_course_card_image(self): 'Found course_card_image %s for assignment %s with metadata %s', image_url, self.assignment.uuid, - self.course_metadata, + self.normalized_metadata, ) return image_url diff --git a/enterprise_access/apps/content_assignments/tests/factories.py b/enterprise_access/apps/content_assignments/tests/factories.py index 0b19e543..14692d77 100644 --- a/enterprise_access/apps/content_assignments/tests/factories.py +++ b/enterprise_access/apps/content_assignments/tests/factories.py @@ -7,7 +7,7 @@ import factory from faker import Faker -from test_utils import random_content_key +from test_utils import random_content_key, random_parent_content_key from ..models import AssignmentConfiguration, LearnerContentAssignment @@ -37,5 +37,6 @@ class Meta: learner_email = factory.LazyAttribute(lambda _: FAKER.email()) lms_user_id = factory.LazyAttribute(lambda _: FAKER.pyint()) content_key = factory.LazyAttribute(lambda _: random_content_key()) + parent_content_key = factory.LazyAttribute(lambda _: random_parent_content_key()) content_title = factory.LazyAttribute(lambda _: f'{FAKER.word()}: a master class') content_quantity = factory.LazyAttribute(lambda _: FAKER.pyfloat(positive=False, right_digits=0)) diff --git a/enterprise_access/apps/content_assignments/tests/test_api.py b/enterprise_access/apps/content_assignments/tests/test_api.py index 3297b789..ae464ece 100644 --- a/enterprise_access/apps/content_assignments/tests/test_api.py +++ b/enterprise_access/apps/content_assignments/tests/test_api.py @@ -626,7 +626,7 @@ def setUpClass(cls): spend_limit=1000000, ) - def mock_content_metadata(self, content_key, enroll_by_date): + def mock_content_metadata(self, content_key, course_run_key, enroll_by_date): """ Helper to produce content metadata with a given enroll_by_date. """ @@ -635,6 +635,11 @@ def mock_content_metadata(self, content_key, enroll_by_date): 'normalized_metadata': { 'enroll_by_date': enroll_by_date, }, + 'normalized_metadata_by_run': { + course_run_key: { + 'enroll_by_date': enroll_by_date, + }, + }, } def test_dont_expire_accepted_assignment(self): @@ -652,7 +657,7 @@ def test_dont_expire_accepted_assignment(self): with mock.patch.object(self.policy, 'subsidy_record', return_value=mock_subsidy_record): expire_assignment( assignment, - content_metadata=self.mock_content_metadata('edX+DemoX', None), + content_metadata=self.mock_content_metadata('edX+DemoX', 'course-v1:edX+DemoX+T2024', None), modify_assignment=True, ) @@ -682,7 +687,11 @@ def test_dont_expire_assignments_with_future_expiration_dates(self, assignment_s with mock.patch.object(self.policy, 'subsidy_record', return_value=mock_subsidy_record): expire_assignment( assignment, - content_metadata=self.mock_content_metadata('edX+DemoX', delta_t(days=100, as_string=True)), + content_metadata=self.mock_content_metadata( + 'edX+DemoX', + 'course-v1:edX+DemoX+T2024', + delta_t(days=100, as_string=True) + ), modify_assignment=True, ) @@ -719,7 +728,7 @@ def test_expire_one_assignment_automatically( with mock.patch.object(self.policy, 'subsidy_record', return_value=mock_subsidy_record): expire_assignment( assignment, - content_metadata=self.mock_content_metadata('edX+DemoX', None), + content_metadata=self.mock_content_metadata('edX+DemoX', 'course-v1:edX+DemoX+T2024', None), modify_assignment=True, ) @@ -747,7 +756,8 @@ def test_expire_assignments_with_passed_enroll_by_date( """ Tests that we expire assignments with a passed enroll_by_date """ - content_key = 'demoX' + content_key = 'edX+DemoX' + course_run_key = 'course-v1:edX+DemoX+T2024' assignment = LearnerContentAssignmentFactory.create( content_key='demoX', assignment_configuration=self.assignment_configuration, @@ -760,6 +770,7 @@ def test_expire_assignments_with_passed_enroll_by_date( # create expired content metadata mock_content_metadata = self.mock_content_metadata( content_key=content_key, + course_run_key=course_run_key, enroll_by_date=delta_t(days=-1, as_string=True), ) @@ -792,7 +803,8 @@ def test_expire_assignments_with_expired_subsidy( """ Tests that we expire assignments with an underlying subsidy that has expired. """ - content_key = 'demoX' + content_key = 'edX+DemoX' + course_run_key = 'course-v1:edX+DemoX+T2024' assignment = LearnerContentAssignmentFactory.create( assignment_configuration=self.assignment_configuration, content_key=content_key, @@ -805,6 +817,7 @@ def test_expire_assignments_with_expired_subsidy( # create non-expired content metadata mock_content_metadata = self.mock_content_metadata( content_key=content_key, + course_run_key=course_run_key, enroll_by_date=delta_t(days=100, as_string=True), ) diff --git a/enterprise_access/utils.py b/enterprise_access/utils.py index 58b4e68b..c87dbd98 100644 --- a/enterprise_access/utils.py +++ b/enterprise_access/utils.py @@ -83,14 +83,14 @@ def _get_subsidy_expiration(assignment): return subsidy_expiration_datetime -def _get_enrollment_deadline_date(content_metadata): +def _get_enrollment_deadline_date(assignment, content_metadata): """ Helper to get the enrollment end date from a content metadata record. """ if not content_metadata: return None - normalized_metadata = content_metadata.get('normalized_metadata') or {} + normalized_metadata = get_normalized_metadata_for_assignment(assignment, content_metadata) enrollment_end_date_str = normalized_metadata.get('enroll_by_date') try: datetime_obj = parse_datetime_string(enrollment_end_date_str) @@ -120,7 +120,7 @@ def get_automatic_expiration_date_and_reason( Arguments: assignment (LearnerContentAssignment): The assignment to check for expiration. - [content_metadata] (dict): Content metadata for the assignment's content key. If not provided, it will be + [metadata_for_assignment] (dict): Content metadata for the assignment's content key. If not provided, it will be fetched and subsequently cached from the content metadata API. """ assignment_configuration = assignment.assignment_configuration @@ -138,7 +138,7 @@ def get_automatic_expiration_date_and_reason( assignments=[assignment], ) content_metadata = content_metadata_by_key.get(content_key) - enrollment_deadline_datetime = _get_enrollment_deadline_date(content_metadata) + enrollment_deadline_datetime = _get_enrollment_deadline_date(assignment, content_metadata) if enrollment_deadline_datetime: enrollment_deadline_datetime = enrollment_deadline_datetime.replace(tzinfo=UTC) @@ -209,3 +209,22 @@ def should_send_email_to_pecu(recent_action): is_65_days_since_invited or is_85_days_since_invited ) + + +def get_normalized_metadata_for_assignment(assignment, content_metadata): + """ + Retrieve normalized metadata for a given object. If the object is associated + with a specific course run, return the metadata for that run. If metadata + for the run is missing, log a warning and return an empty dictionary. + + Args: + obj (dict): A dictionary containing `content_metadata` and `assignment_course_run_key`. + + Returns: + dict: Normalized metadata, either for the specific course run or the advertised course run, if any. + """ + if not assignment.is_assigned_course_run: + return content_metadata.get('normalized_metadata', {}) + + normalized_metadata_by_run = content_metadata.get('normalized_metadata_by_run', {}) + return normalized_metadata_by_run.get(assignment.content_key, {}) diff --git a/test_utils/__init__.py b/test_utils/__init__.py index 209061ee..71f4a499 100644 --- a/test_utils/__init__.py +++ b/test_utils/__init__.py @@ -78,6 +78,17 @@ def random_content_key(): return 'course-v1:{}+{}+{}'.format(*fake_words) +def random_parent_content_key(): + """ + Helper to craft a random content key. + """ + fake_words = [ + FAKER.word() + str(FAKER.random_int()) + for _ in range(2) + ] + return '{}+{}'.format(*fake_words) + + @mark.django_db class APITest(APITestCase): """