From 16e2f11045e6beb02adb19a45c0d3077223cfe12 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 12 Sep 2024 10:29:49 -0400 Subject: [PATCH] feat: support course run based assignments in credits_available, expiration, emails, etc. (#561) --- .../content_assignments/assignment.py | 40 ++- .../apps/api/v1/tests/test_allocation_view.py | 26 +- .../api/v1/tests/test_assignment_views.py | 8 + .../tests/test_subsidy_access_policy_views.py | 29 +- .../api_client/enterprise_catalog_client.py | 2 +- .../apps/content_assignments/api.py | 11 +- .../content_metadata_api.py | 30 +- .../automatically_nudge_assignments.py | 22 +- .../test_automatically_nudge_assignments.py | 323 +++++++++++------- .../apps/content_assignments/tasks.py | 22 +- .../content_assignments/tests/factories.py | 3 +- .../content_assignments/tests/test_api.py | 25 +- enterprise_access/utils.py | 26 +- test_utils/__init__.py | 11 + 14 files changed, 380 insertions(+), 198 deletions(-) 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..fac8c7f0 100644 --- a/enterprise_access/apps/api/v1/tests/test_allocation_view.py +++ b/enterprise_access/apps/api/v1/tests/test_allocation_view.py @@ -65,7 +65,8 @@ class TestSubsidyAccessPolicyAllocationView(APITestWithMocks): def setUpTestData(cls): super().setUpTestData() cls.enterprise_uuid = TEST_ENTERPRISE_UUID - cls.content_key = 'course-v1:edX+edXPrivacy101+3T2020' + cls.content_key = 'course-v1:edX+Privacy101+3T2020' + cls.parent_content_key = 'edX+Privacy101' 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, @@ -150,13 +157,13 @@ def setUp(self): }, { 'learner_emails': ['everything-valid@example.com'], - 'content_key': 'course-v1:edX+edXPrivacy101+3T2020', # valid course run key + 'content_key': 'course-v1:edX+Privacy101+3T2020', # valid course run key 'content_price_cents': 100, 'error_regex': '', }, { 'learner_emails': ['everything-valid@example.com'], - 'content_key': 'course-v1:edX+edXPrivacy101+3T2020', # valid course run key + 'content_key': 'course-v1:edX+Privacy101+3T2020', # valid course run key 'content_price_cents': -100, 'error_regex': 'Ensure this value is greater than or equal to 0', }, @@ -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..d4a6dddc 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 @@ -1097,7 +1097,7 @@ def test_policy_redemption_forbidden_requests(self, role_context_dict, expected_ url = reverse('api:v1:policy-redemption-redeem', kwargs={'policy_uuid': self.redeemable_policy.uuid}) payload = { 'lms_user_id': 1234, - 'content_key': 'course-v1:edX+edXPrivacy101+3T2020', + 'content_key': 'course-v1:edX+Privacy101+3T2020', } response = self.client.post(url, payload) self.assertEqual(response.status_code, expected_response_code) @@ -1116,7 +1116,7 @@ def test_policy_redemption_forbidden_requests(self, role_context_dict, expected_ kwargs={"enterprise_customer_uuid": self.enterprise_uuid}, ) query_params = { - 'content_key': ['course-v1:edX+edXPrivacy101+3T2020', 'course-v1:edX+edXPrivacy101+3T2020_2'], + 'content_key': ['course-v1:edX+Privacy101+3T2020', 'course-v1:edX+Privacy101+3T2020_2'], } response = self.client.get(url, query_params) self.assertEqual(response.status_code, expected_response_code) @@ -1223,7 +1223,7 @@ def test_redeem_policy(self, mock_transactions_cache_for_learner, mock_oauth): self.redeemable_policy.subsidy_client.create_subsidy_transaction.return_value = mock_transaction_record payload = { 'lms_user_id': 1234, - 'content_key': 'course-v1:edX+edXPrivacy101+3T2020', + 'content_key': 'course-v1:edX+Privacy101+3T2020', } response = self.client.post(self.subsidy_access_policy_redeem_endpoint, payload) @@ -1261,7 +1261,7 @@ def test_redeem_policy_with_metadata(self, mock_transactions_cache_for_learner): self.redeemable_policy.subsidy_client.create_subsidy_transaction.return_value = mock_transaction_record payload = { 'lms_user_id': 1234, - 'content_key': 'course-v1:edX+edXPrivacy101+3T2020', + 'content_key': 'course-v1:edX+Privacy101+3T2020', 'metadata': { 'geag_first_name': 'John' } @@ -1335,7 +1335,7 @@ def test_redeem_policy_redemption_idempotency_key_versions( self.mock_get_content_metadata.return_value = {'content_price': 5000} lms_user_id = 1234 - content_key = 'course-v1:edX+edXPrivacy101+3T2020' + content_key = 'course-v1:edX+Privacy101+3T2020' historical_redemption_uuid = str(uuid4()) baseline_idempotency_key = create_idempotency_key_for_transaction( subsidy_uuid=str(self.redeemable_policy.subsidy_uuid), @@ -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'}, @@ -1835,8 +1842,8 @@ def test_can_redeem_policy(self, mock_transactions_cache_for_learner): 'total_quantity': 0, }, } - test_content_key_1 = "course-v1:edX+edXPrivacy101+3T2020" - test_content_key_2 = "course-v1:edX+edXPrivacy101+3T2020_2" + test_content_key_1 = "course-v1:edX+Privacy101+3T2020" + test_content_key_2 = "course-v1:edX+Privacy101+3T2020_2" test_content_key_1_metadata_price = 29900 test_content_key_2_metadata_price = 81900 test_content_key_1_usd_price = 299 @@ -1947,8 +1954,8 @@ def test_can_redeem_policy_none_redeemable( 'unit': 'usd_cents', 'all_transactions': [], } - test_content_key_1 = "course-v1:edX+edXPrivacy101+3T2020" - test_content_key_2 = "course-v1:edX+edXPrivacy101+3T2020_2" + test_content_key_1 = "course-v1:edX+Privacy101+3T2020" + test_content_key_2 = "course-v1:edX+Privacy101+3T2020_2" test_content_key_1_metadata_price = 29900 test_content_key_2_metadata_price = 81900 diff --git a/enterprise_access/apps/api_client/enterprise_catalog_client.py b/enterprise_access/apps/api_client/enterprise_catalog_client.py index 72793fcf..f56c064b 100644 --- a/enterprise_access/apps/api_client/enterprise_catalog_client.py +++ b/enterprise_access/apps/api_client/enterprise_catalog_client.py @@ -65,7 +65,7 @@ def catalog_content_metadata(self, catalog_uuid, content_keys, traverse_paginati 'traverse_pagination': traverse_pagination, **kwargs, } - endpoint = self.enterprise_catalog_endpoint + str(catalog_uuid) + '/get_content_metadata/' + endpoint = f'{self.enterprise_catalog_endpoint}{catalog_uuid}/get_content_metadata/' response = self.client.get(endpoint, params=query_params) response.raise_for_status() diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index 0de0dfc8..bd707755 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 @@ -762,7 +767,9 @@ def nudge_assignments(assignments, assignment_configuration_uuid, days_before_co [assignment], ) content_metadata = content_metadata_for_assignments.get(assignment.content_key, {}) - start_date = content_metadata.get('normalized_metadata', {}).get('start_date') + normalized_metadata = get_normalized_metadata_for_assignment(assignment, content_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..04661e56 100644 --- a/enterprise_access/apps/content_assignments/content_metadata_api.py +++ b/enterprise_access/apps/content_assignments/content_metadata_api.py @@ -17,26 +17,44 @@ DEFAULT_STRFTIME_PATTERN = '%b %d, %Y' +def _content_metadata_for_assignment(assignment, course_metadata_list): + """ + Given a list of course metadata dictionaries and an assignment, + find the course metadata dictionary that corresponds to the + assignment's content_key (course run) or parent_content_key (course). + """ + return next( + ( + course_metadata + for course_metadata in course_metadata_list + if course_metadata.get('key') in (assignment.content_key, assignment.parent_content_key) + ), + None + ) + + def get_content_metadata_for_assignments(enterprise_catalog_uuid, assignments): """ Fetches (from cache or enterprise-catalog API call) content metadata in bulk for the `content_keys` of the given assignments, provided such metadata is related to the given `enterprise_catalog_uuid`. + Note that the `content_keys` of the provided assignments may be + either course run keys or course keys. Regardless of the type of key, + the content metadata API will return the metadata at the course-level. + Returns: A dict mapping every content key of the provided 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) + content_keys = {assignment.content_key for assignment in assignments} + course_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 - } - return { - assignment.content_key: metadata_by_key.get(assignment.content_key) + assignment.content_key: _content_metadata_for_assignment(assignment, course_metadata_list) for assignment in assignments } + return metadata_by_key def get_card_image_url(content_metadata): 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..cd35a5da 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 datetime_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..78a8c004 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): + """ + Returns a normalized metadata dictionary for the assignment. + """ + 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( 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..1ca3c8bc 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) @@ -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,23 @@ 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: + assignment (dict): The assignment object. + content_metadata (dict): The content metadata object + + Returns: + dict: Normalized metadata, either for a 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): """