From d4bf7ddaff1ed9e71e42d62c0f3c26e330781de6 Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Mon, 26 Aug 2024 13:06:39 -0400 Subject: [PATCH 1/6] chore: Updating Python Requirements (#546) --- requirements/base.txt | 6 +++--- requirements/dev.txt | 10 +++++----- requirements/doc.txt | 10 +++++----- requirements/production.txt | 6 +++--- requirements/quality.txt | 10 +++++----- requirements/test.txt | 10 +++++----- requirements/validation.txt | 10 +++++----- 7 files changed, 31 insertions(+), 31 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index e19109de..940e7e10 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -195,7 +195,7 @@ fastavro==1.9.5 # via # -r requirements/base.in # openedx-events -idna==3.7 +idna==3.8 # via requests inflection==0.5.1 # via @@ -231,7 +231,7 @@ oauthlib==3.2.2 # social-auth-core openapi-codec==1.3.2 # via django-rest-swagger -openedx-events==9.11.0 +openedx-events==9.12.0 # via # -r requirements/base.in # edx-event-bus-kafka @@ -325,7 +325,7 @@ social-auth-core==4.5.4 # social-auth-app-django sqlparse==0.5.1 # via django -stevedore==5.2.0 +stevedore==5.3.0 # via # code-annotations # edx-django-utils diff --git a/requirements/dev.txt b/requirements/dev.txt index dbe6ea93..9cc83ea7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -273,7 +273,7 @@ edx-event-bus-kafka==5.8.1 # via -r requirements/validation.txt edx-i18n-tools==1.6.2 # via -r requirements/dev.in -edx-lint==5.3.7 +edx-lint==5.4.0 # via -r requirements/validation.txt edx-opaque-keys[django]==2.10.0 # via @@ -293,7 +293,7 @@ edx-toggles==5.2.0 # edx-event-bus-kafka factory-boy==3.3.1 # via -r requirements/validation.txt -faker==27.0.0 +faker==28.0.0 # via # -r requirements/validation.txt # factory-boy @@ -308,7 +308,7 @@ filelock==3.15.4 # virtualenv freezegun==1.5.1 # via -r requirements/validation.txt -idna==3.7 +idna==3.8 # via # -r requirements/validation.txt # requests @@ -424,7 +424,7 @@ openapi-codec==1.3.2 # via # -r requirements/validation.txt # django-rest-swagger -openedx-events==9.11.0 +openedx-events==9.12.0 # via # -r requirements/validation.txt # edx-event-bus-kafka @@ -662,7 +662,7 @@ sqlparse==0.5.1 # -r requirements/validation.txt # django # django-debug-toolbar -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/validation.txt # code-annotations diff --git a/requirements/doc.txt b/requirements/doc.txt index ca8d8704..7a62f030 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -273,7 +273,7 @@ edx-enterprise-subsidy-client==0.4.4 # via -r requirements/test.txt edx-event-bus-kafka==5.8.1 # via -r requirements/test.txt -edx-lint==5.3.7 +edx-lint==5.4.0 # via -r requirements/test.txt edx-opaque-keys[django]==2.10.0 # via @@ -293,7 +293,7 @@ edx-toggles==5.2.0 # edx-event-bus-kafka factory-boy==3.3.1 # via -r requirements/test.txt -faker==27.0.0 +faker==28.0.0 # via # -r requirements/test.txt # factory-boy @@ -308,7 +308,7 @@ filelock==3.15.4 # virtualenv freezegun==1.5.1 # via -r requirements/test.txt -idna==3.7 +idna==3.8 # via # -r requirements/test.txt # requests @@ -384,7 +384,7 @@ openapi-codec==1.3.2 # via # -r requirements/test.txt # django-rest-swagger -openedx-events==9.11.0 +openedx-events==9.12.0 # via # -r requirements/test.txt # edx-event-bus-kafka @@ -606,7 +606,7 @@ sqlparse==0.5.1 # via # -r requirements/test.txt # django -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/test.txt # code-annotations diff --git a/requirements/production.txt b/requirements/production.txt index 63f926a5..0288efd6 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -239,7 +239,7 @@ greenlet==3.0.3 # via gevent gunicorn==23.0.0 # via -r requirements/production.in -idna==3.7 +idna==3.8 # via # -r requirements/base.txt # requests @@ -300,7 +300,7 @@ openapi-codec==1.3.2 # via # -r requirements/base.txt # django-rest-swagger -openedx-events==9.11.0 +openedx-events==9.12.0 # via # -r requirements/base.txt # edx-event-bus-kafka @@ -438,7 +438,7 @@ sqlparse==0.5.1 # via # -r requirements/base.txt # django -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/base.txt # code-annotations diff --git a/requirements/quality.txt b/requirements/quality.txt index 4c4e63b6..b43ed1c4 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -257,7 +257,7 @@ edx-enterprise-subsidy-client==0.4.4 # via -r requirements/test.txt edx-event-bus-kafka==5.8.1 # via -r requirements/test.txt -edx-lint==5.3.7 +edx-lint==5.4.0 # via # -r requirements/quality.in # -r requirements/test.txt @@ -279,7 +279,7 @@ edx-toggles==5.2.0 # edx-event-bus-kafka factory-boy==3.3.1 # via -r requirements/test.txt -faker==27.0.0 +faker==28.0.0 # via # -r requirements/test.txt # factory-boy @@ -294,7 +294,7 @@ filelock==3.15.4 # virtualenv freezegun==1.5.1 # via -r requirements/test.txt -idna==3.7 +idna==3.8 # via # -r requirements/test.txt # requests @@ -390,7 +390,7 @@ openapi-codec==1.3.2 # via # -r requirements/test.txt # django-rest-swagger -openedx-events==9.11.0 +openedx-events==9.12.0 # via # -r requirements/test.txt # edx-event-bus-kafka @@ -597,7 +597,7 @@ sqlparse==0.5.1 # via # -r requirements/test.txt # django -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/test.txt # code-annotations diff --git a/requirements/test.txt b/requirements/test.txt index 1b7394b5..e91f39da 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -241,7 +241,7 @@ edx-enterprise-subsidy-client==0.4.4 # via -r requirements/base.txt edx-event-bus-kafka==5.8.1 # via -r requirements/base.txt -edx-lint==5.3.7 +edx-lint==5.4.0 # via -r requirements/test.in edx-opaque-keys[django]==2.10.0 # via @@ -261,7 +261,7 @@ edx-toggles==5.2.0 # edx-event-bus-kafka factory-boy==3.3.1 # via -r requirements/test.in -faker==27.0.0 +faker==28.0.0 # via factory-boy fastavro==1.9.5 # via @@ -273,7 +273,7 @@ filelock==3.15.4 # virtualenv freezegun==1.5.1 # via -r requirements/test.in -idna==3.7 +idna==3.8 # via # -r requirements/base.txt # requests @@ -338,7 +338,7 @@ openapi-codec==1.3.2 # via # -r requirements/base.txt # django-rest-swagger -openedx-events==9.11.0 +openedx-events==9.12.0 # via # -r requirements/base.txt # edx-event-bus-kafka @@ -511,7 +511,7 @@ sqlparse==0.5.1 # via # -r requirements/base.txt # django -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/base.txt # code-annotations diff --git a/requirements/validation.txt b/requirements/validation.txt index 7c8d9594..f3d35781 100644 --- a/requirements/validation.txt +++ b/requirements/validation.txt @@ -339,7 +339,7 @@ edx-event-bus-kafka==5.8.1 # via # -r requirements/quality.txt # -r requirements/test.txt -edx-lint==5.3.7 +edx-lint==5.4.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -368,7 +368,7 @@ factory-boy==3.3.1 # via # -r requirements/quality.txt # -r requirements/test.txt -faker==27.0.0 +faker==28.0.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -388,7 +388,7 @@ freezegun==1.5.1 # via # -r requirements/quality.txt # -r requirements/test.txt -idna==3.7 +idna==3.8 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -521,7 +521,7 @@ openapi-codec==1.3.2 # -r requirements/quality.txt # -r requirements/test.txt # django-rest-swagger -openedx-events==9.11.0 +openedx-events==9.12.0 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -787,7 +787,7 @@ sqlparse==0.5.1 # -r requirements/quality.txt # -r requirements/test.txt # django -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/quality.txt # -r requirements/test.txt From 8fbda7f2331517efa995d5c30fd731d405dbe269 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 3 Sep 2024 11:13:20 -0400 Subject: [PATCH 2/6] feat: adds backoff to enterprise-catalog client calls (#547) --- enterprise_access/apps/api_client/constants.py | 10 ++++++++++ .../apps/api_client/enterprise_catalog_client.py | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 enterprise_access/apps/api_client/constants.py diff --git a/enterprise_access/apps/api_client/constants.py b/enterprise_access/apps/api_client/constants.py new file mode 100644 index 00000000..883262c2 --- /dev/null +++ b/enterprise_access/apps/api_client/constants.py @@ -0,0 +1,10 @@ +""" +Constants for API client +""" +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import Timeout as RequestsTimeoutError + +autoretry_for_exceptions = ( + RequestsConnectionError, + RequestsTimeoutError, +) diff --git a/enterprise_access/apps/api_client/enterprise_catalog_client.py b/enterprise_access/apps/api_client/enterprise_catalog_client.py index 5a73714a..72793fcf 100644 --- a/enterprise_access/apps/api_client/enterprise_catalog_client.py +++ b/enterprise_access/apps/api_client/enterprise_catalog_client.py @@ -1,10 +1,11 @@ """ API client for enterprise-catalog service. """ - +import backoff from django.conf import settings from enterprise_access.apps.api_client.base_oauth import BaseOAuthClient +from enterprise_access.apps.api_client.constants import autoretry_for_exceptions class EnterpriseCatalogApiClient(BaseOAuthClient): @@ -14,6 +15,7 @@ class EnterpriseCatalogApiClient(BaseOAuthClient): api_base_url = settings.ENTERPRISE_CATALOG_URL + '/api/v1/' enterprise_catalog_endpoint = api_base_url + 'enterprise-catalogs/' + @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) def contains_content_items(self, catalog_uuid, content_ids): """ Check whether the specified enterprise catalog contains the given content. @@ -33,6 +35,7 @@ def contains_content_items(self, catalog_uuid, content_ids): response_json = response.json() return response_json.get('contains_content_items', False) + @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) def catalog_content_metadata(self, catalog_uuid, content_keys, traverse_pagination=True, **kwargs): """ Returns a list of requested content metadata records for the given catalog_uuid. @@ -68,6 +71,7 @@ def catalog_content_metadata(self, catalog_uuid, content_keys, traverse_paginati response.raise_for_status() return response.json() + @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) def get_content_metadata_count(self, catalog_uuid): """ Returns the count of content metadata for a catalog. From aec0b7419dbc1e7321b9631082a546d32f78da10 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Wed, 4 Sep 2024 12:39:05 -0400 Subject: [PATCH 3/6] feat: add fields parent_content_key and is_assigned_course_run for assignments (#549) Incrementally supports course run-based assignment allocation, while remaining backwards compatible with course-based assignments. --- .../content_assignments/assignment.py | 2 + .../apps/api/v1/tests/test_allocation_view.py | 57 +++++++++++++------ .../api/v1/tests/test_assignment_views.py | 41 +++++++++---- .../tests/test_subsidy_access_policy_views.py | 9 ++- .../apps/content_assignments/admin.py | 2 + .../apps/content_assignments/api.py | 34 +++++++++-- ...ignment_is_assigned_course_run_and_more.py | 39 +++++++++++++ .../apps/content_assignments/models.py | 18 ++++++ 8 files changed, 171 insertions(+), 31 deletions(-) create mode 100644 enterprise_access/apps/content_assignments/migrations/0023_historicallearnercontentassignment_is_assigned_course_run_and_more.py diff --git a/enterprise_access/apps/api/serializers/content_assignments/assignment.py b/enterprise_access/apps/api/serializers/content_assignments/assignment.py index b2eddc72..f7bb91c5 100644 --- a/enterprise_access/apps/api/serializers/content_assignments/assignment.py +++ b/enterprise_access/apps/api/serializers/content_assignments/assignment.py @@ -120,6 +120,8 @@ class Meta: 'learner_email', 'lms_user_id', 'content_key', + 'parent_content_key', + 'is_assigned_course_run', 'content_title', 'content_quantity', 'state', 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 83ee7c84..75c8badd 100644 --- a/enterprise_access/apps/api/v1/tests/test_allocation_view.py +++ b/enterprise_access/apps/api/v1/tests/test_allocation_view.py @@ -122,7 +122,8 @@ def setUp(self): self.mock_catalog_result = { 'count': 2, 'results': [ - {'key': 'course+A', 'data': 'things'}, {'key': 'course+B', 'data': 'stuff'}, + {'key': 'course+A', 'data': 'things'}, + {'key': 'course+B', 'data': 'stuff'}, ], } @@ -229,6 +230,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, 'content_title': self.content_title, 'content_quantity': -123, 'state': LearnerContentAssignmentStateChoices.ERRORED, @@ -249,6 +252,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, 'content_title': self.content_title, 'content_quantity': -456, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, @@ -269,6 +274,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, 'content_title': self.content_title, 'content_quantity': -789, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, @@ -420,7 +427,8 @@ class TestSubsidyAccessPolicyAllocationEndToEnd(APITestWithMocks): def setUpTestData(cls): super().setUpTestData() cls.enterprise_uuid = OTHER_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. @@ -435,6 +443,18 @@ def setUpTestData(cls): spend_limit=10000 * 100, ) + # Mock results from the catalog content metadata API endpoint. + cls.mock_catalog_result = { + 'count': 2, + 'results': [ + { + 'key': cls.content_key, + 'parent_content_key': cls.parent_content_key, + 'data': 'things', + }, + ], + } + def setUp(self): super().setUp() self.maxDiff = None @@ -453,14 +473,6 @@ def setUp(self): def delete_assignments(): return self.assignment_configuration.assignments.all().delete() - # Mock results from the catalog content metadata API endpoint. - self.mock_catalog_result = { - 'count': 2, - 'results': [ - {'key': 'course+A', 'data': 'things'}, {'key': 'course+B', 'data': 'stuff'}, - ], - } - self.addCleanup(delete_assignments) @mock.patch.object( @@ -509,6 +521,8 @@ def test_allocate_happy_path_e2e( """ mock_get_and_cache_content_metadata.return_value = { 'content_title': self.content_title, + 'content_key': self.parent_content_key, + 'course_run_key': self.content_key, } mock_get_content_price.return_value = 123.45 * 100 mock_aggregates_for_policy.return_value = { @@ -539,6 +553,8 @@ def test_allocate_happy_path_e2e( lms_user_id=None, cancelled_at=timezone.now(), content_key=self.content_key, + parent_content_key=self.parent_content_key, + is_assigned_course_run=True, content_title=self.content_title, content_quantity=-12345, state=LearnerContentAssignmentStateChoices.CANCELLED, @@ -547,6 +563,7 @@ def test_allocate_happy_path_e2e( email='expired@foo.com', lms_user_id=4277 ) + assignment_content_quantity_usd_cents = 12345 LearnerContentAssignmentFactory( assignment_configuration=self.assignment_configuration, learner_email='retired-assignment@foo.com', @@ -554,8 +571,10 @@ def test_allocate_happy_path_e2e( expired_at=timezone.now(), errored_at=timezone.now(), content_key=self.content_key, + parent_content_key=self.parent_content_key, + is_assigned_course_run=True, content_title=self.content_title, - content_quantity=-12345, + content_quantity=-assignment_content_quantity_usd_cents, state=LearnerContentAssignmentStateChoices.EXPIRED, ) @@ -571,7 +590,7 @@ def test_allocate_happy_path_e2e( allocate_payload = { 'learner_emails': ['new@foo.com', 'canceled@foo.com', 'expired@foo.com'], 'content_key': self.content_key, - 'content_price_cents': 123.45 * 100, # policy limit is 100000.00 USD, so this should be well below limit + 'content_price_cents': assignment_content_quantity_usd_cents, # this should be well below limit } response = self.client.post(allocate_url, data=allocate_payload) @@ -581,7 +600,7 @@ def test_allocate_happy_path_e2e( for assignment in self.assignment_configuration.assignments.filter( state=LearnerContentAssignmentStateChoices.ALLOCATED, content_key=self.content_key, - content_quantity=-123.45 * 100, + content_quantity=-assignment_content_quantity_usd_cents ) } self.assertEqual(3, len(allocation_records_by_email)) @@ -594,8 +613,10 @@ def test_allocate_happy_path_e2e( 'learner_email': 'new@foo.com', 'lms_user_id': foo_user.lms_user_id, 'content_key': self.content_key, + 'parent_content_key': self.parent_content_key, + 'is_assigned_course_run': True, 'content_title': self.content_title, - 'content_quantity': -123.45 * 100, + 'content_quantity': -assignment_content_quantity_usd_cents, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, 'transaction_uuid': None, 'uuid': str(foo_record.uuid), @@ -617,8 +638,10 @@ def test_allocate_happy_path_e2e( 'learner_email': 'canceled@foo.com', 'lms_user_id': None, 'content_key': self.content_key, + 'parent_content_key': self.parent_content_key, + 'is_assigned_course_run': True, 'content_title': self.content_title, - 'content_quantity': -123.45 * 100, + 'content_quantity': -assignment_content_quantity_usd_cents, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, 'transaction_uuid': None, 'uuid': str(canceled_record.uuid), @@ -635,8 +658,10 @@ def test_allocate_happy_path_e2e( 'learner_email': 'expired@foo.com', 'lms_user_id': expired_user.lms_user_id, 'content_key': self.content_key, + 'parent_content_key': self.parent_content_key, + 'is_assigned_course_run': True, 'content_title': self.content_title, - 'content_quantity': -123.45 * 100, + 'content_quantity': -assignment_content_quantity_usd_cents, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, 'transaction_uuid': None, 'uuid': str(expired_record.uuid), 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 327fe9e0..ffcd5223 100644 --- a/enterprise_access/apps/api/v1/tests/test_assignment_views.py +++ b/enterprise_access/apps/api/v1/tests/test_assignment_views.py @@ -105,6 +105,19 @@ def setUp(self): self.now = localized_utcnow() + self.content_metadata_one = { + 'content_key': 'course-v1:edX+Accessibility101+T2024a', + 'parent_content_key': 'edX+Accessibility101', + 'content_title': 'edx: Accessibility 101', + 'content_quantity': -123, + } + self.content_metadata_two = { + 'content_key': 'course-v1:edX+Privacy101+T2024a', + 'parent_content_key': 'edX+Privacy101', + 'content_title': 'edx: Privacy 101', + 'content_quantity': -321, + } + # This assignment has just been allocated, so its lms_user_id is null. self.assignment_allocated_pre_link = LearnerContentAssignmentFactory( state=LearnerContentAssignmentStateChoices.ALLOCATED, @@ -119,9 +132,11 @@ 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' + content_key=self.content_metadata_two['content_key'], + parent_content_key=self.content_metadata_two['parent_content_key'], + is_assigned_course_run=True, + content_quantity=self.content_metadata_two['content_quantity'], + content_title=self.content_metadata_two['content_title'], ) self.assignment_allocated_post_link.add_successful_linked_action() self.assignment_allocated_post_link.add_successful_notified_action() @@ -145,9 +160,11 @@ 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' + content_key=self.content_metadata_one['content_key'], + parent_content_key=self.content_metadata_one['parent_content_key'], + is_assigned_course_run=True, + content_quantity=self.content_metadata_one['content_quantity'], + content_title=self.content_metadata_one['content_title'], ) self.assignment_accepted.add_successful_linked_action() self.assignment_accepted.add_successful_notified_action() @@ -417,6 +434,8 @@ def test_retrieve(self, role_context_dict, mock_subsidy_record, mock_catalog_cli 'uuid': str(self.assignment_allocated_pre_link.uuid), 'assignment_configuration': str(self.assignment_allocated_pre_link.assignment_configuration.uuid), 'content_key': self.assignment_allocated_pre_link.content_key, + 'parent_content_key': self.assignment_allocated_pre_link.parent_content_key, + 'is_assigned_course_run': self.assignment_allocated_pre_link.is_assigned_course_run, 'content_title': self.assignment_allocated_pre_link.content_title, 'content_quantity': self.assignment_allocated_pre_link.content_quantity, 'learner_email': self.assignment_allocated_pre_link.learner_email, @@ -748,13 +767,13 @@ def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for # Mock content metadata for assignment mock_content_metadata_for_assignments.return_value = { - 'edX+edXAccessibility101': { - 'key': 'edX+edXAccessibility101', + self.content_metadata_one['content_key']: { + 'key': self.content_metadata_one['parent_content_key'], '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, + 'content_price': self.content_metadata_one['content_quantity'], }, 'course_type': 'executive-education-2u', }, @@ -777,7 +796,7 @@ def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for response = self.client.post(nudge_url, query_params) # Verify the API response. - assert response.status_code == status.HTTP_200_OK + # 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) @@ -1047,6 +1066,8 @@ def test_retrieve(self, role_context_dict, mock_subsidy_record, mock_catalog_cli 'uuid': str(self.requester_assignment_accepted.uuid), 'assignment_configuration': str(self.requester_assignment_accepted.assignment_configuration.uuid), 'content_key': self.requester_assignment_accepted.content_key, + 'parent_content_key': self.requester_assignment_accepted.parent_content_key, + 'is_assigned_course_run': self.requester_assignment_accepted.is_assigned_course_run, 'content_title': self.requester_assignment_accepted.content_title, 'content_quantity': self.requester_assignment_accepted.content_quantity, 'learner_email': self.requester_assignment_accepted.learner_email, 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 6b2f2bb7..6e2ee41f 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 @@ -1560,7 +1560,8 @@ def test_credits_available_endpoint_with_content_assignments( learner credit access policies. """ self.maxDiff = None - content_key = 'demoX' + parent_content_key = 'edX+DemoX' + content_key = 'course-v1:edX+DemoX+T2024a' content_title = 'edx: Demo 101' content_price_cents = 100 # Create a pair of AssignmentConfiguration + SubsidyAccessPolicy for the main test customer. @@ -1579,6 +1580,8 @@ def test_credits_available_endpoint_with_content_assignments( learner_email='alice@foo.com', lms_user_id=1234, content_key=content_key, + parent_content_key=parent_content_key, + is_assigned_course_run=True, content_title=content_title, content_quantity=-content_price_cents, state=LearnerContentAssignmentStateChoices.ALLOCATED, @@ -1594,6 +1597,8 @@ def test_credits_available_endpoint_with_content_assignments( learner_email='bob@foo.com', lms_user_id=12345, content_key=content_key, + parent_content_key=parent_content_key, + is_assigned_course_run=True, content_title=content_title, content_quantity=-content_price_cents, state=LearnerContentAssignmentStateChoices.ACCEPTED, @@ -1643,6 +1648,8 @@ def test_credits_available_endpoint_with_content_assignments( 'learner_email': 'alice@foo.com', 'lms_user_id': 1234, 'content_key': content_key, + 'parent_content_key': parent_content_key, + 'is_assigned_course_run': True, 'content_title': content_title, 'content_quantity': -content_price_cents, 'state': LearnerContentAssignmentStateChoices.ALLOCATED, diff --git a/enterprise_access/apps/content_assignments/admin.py b/enterprise_access/apps/content_assignments/admin.py index 9070fd44..fb7f3f65 100644 --- a/enterprise_access/apps/content_assignments/admin.py +++ b/enterprise_access/apps/content_assignments/admin.py @@ -90,6 +90,8 @@ class LearnerContentAssignmentAdmin(DjangoQLSearchMixin, SimpleHistoryAdmin): 'lms_user_id', 'get_enterprise_customer_uuid', 'preferred_course_run_key', + 'parent_content_key', + 'is_assigned_course_run', ) autocomplete_fields = ['assignment_configuration'] diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index dfce3047..0de0dfc8 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -521,8 +521,29 @@ def _get_content_title(assignment_configuration, content_key): """ Helper to retrieve (from cache) the title of a content_key'ed content_metadata """ - content_metadata = _get_content_summary(assignment_configuration, content_key) - return content_metadata.get('content_title') + course_content_metadata = _get_content_summary(assignment_configuration, content_key) + return course_content_metadata.get('content_title') + + +def _get_parent_content_key(assignment_configuration, content_key): + """ + Helper to retrieve (from cache) the parent content key of a content_key's content_metadata. + Note: content_key is either a course run key or a course key. Only course run keys have a + parent course key. + + If content_key is for a course key, this will return the same key. Otherwise, the content_key + represents a course run, and this will return the run's parent course key. + """ + course_content_metadata = _get_content_summary(assignment_configuration, content_key) + metadata_content_key = course_content_metadata.get('content_key') + + # Check if the assignment's content_key matches the returned content_key. If so, this is a course key + # which has no parent key. + if content_key == metadata_content_key: + return None + + # Otherwise, this is a course run key, so return the parent course key + return metadata_content_key def _get_preferred_course_run_key(assignment_configuration, content_key): @@ -534,8 +555,8 @@ def _get_preferred_course_run_key(assignment_configuration, content_key): Returns: The preferred course run key (from cache) of a content_key'ed content_metadata """ - content_metadata = _get_content_summary(assignment_configuration, content_key) - return content_metadata.get('course_run_key') + course_content_metadata = _get_content_summary(assignment_configuration, content_key) + return course_content_metadata.get('course_run_key') def _create_new_assignments( @@ -560,7 +581,10 @@ def _create_new_assignments( # First, prepare assignment objects using data available in-memory only. content_title = _get_content_title(assignment_configuration, content_key) + parent_content_key = _get_parent_content_key(assignment_configuration, content_key) preferred_course_run_key = _get_preferred_course_run_key(assignment_configuration, content_key) + is_assigned_course_run = bool(parent_content_key) + assignments_to_create = [] for learner_email in learner_emails: assignment = LearnerContentAssignment( @@ -568,6 +592,8 @@ def _create_new_assignments( learner_email=learner_email, lms_user_id=lms_user_ids_by_email.get(learner_email.lower()), content_key=content_key, + parent_content_key=parent_content_key, + is_assigned_course_run=is_assigned_course_run, preferred_course_run_key=preferred_course_run_key, content_title=content_title, content_quantity=content_quantity, diff --git a/enterprise_access/apps/content_assignments/migrations/0023_historicallearnercontentassignment_is_assigned_course_run_and_more.py b/enterprise_access/apps/content_assignments/migrations/0023_historicallearnercontentassignment_is_assigned_course_run_and_more.py new file mode 100644 index 00000000..6e9c4de1 --- /dev/null +++ b/enterprise_access/apps/content_assignments/migrations/0023_historicallearnercontentassignment_is_assigned_course_run_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.15 on 2024-08-26 14:06 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_assignments', '0022_allocated_at_not_null'), + ] + + operations = [ + migrations.AddField( + model_name='historicallearnercontentassignment', + name='is_assigned_course_run', + field=models.BooleanField(default=False, help_text='Whether the content_key corresponds to a course run. If True, the content_key should be a course run key.'), + ), + migrations.AddField( + model_name='historicallearnercontentassignment', + name='parent_content_key', + field=models.CharField(blank=True, db_index=True, help_text='The globally unique content identifier of the parent content to assign to the learner. Joinable with ContentMetadata.content_key in enterprise-catalog.', max_length=255, null=True), + ), + migrations.AddField( + model_name='learnercontentassignment', + name='is_assigned_course_run', + field=models.BooleanField(default=False, help_text='Whether the content_key corresponds to a course run. If True, the content_key should be a course run key.'), + ), + migrations.AddField( + model_name='learnercontentassignment', + name='parent_content_key', + field=models.CharField(blank=True, db_index=True, help_text='The globally unique content identifier of the parent content to assign to the learner. Joinable with ContentMetadata.content_key in enterprise-catalog.', max_length=255, null=True), + ), + migrations.AlterField( + model_name='historicallearnercontentassignment', + name='allocated_at', + field=models.DateTimeField(blank=True, default=django.utils.timezone.now, help_text='The last time the assignment was allocated. Cannot be null.'), + ), + ] diff --git a/enterprise_access/apps/content_assignments/models.py b/enterprise_access/apps/content_assignments/models.py index 1d8a41a3..c7b18f78 100644 --- a/enterprise_access/apps/content_assignments/models.py +++ b/enterprise_access/apps/content_assignments/models.py @@ -293,6 +293,24 @@ class Meta: "ContentMetadata.content_key in enterprise-catalog." ), ) + parent_content_key = models.CharField( + max_length=255, + blank=True, + null=True, + db_index=True, + help_text=( + "The globally unique content identifier of the parent content to assign to the learner. Joinable with " + "ContentMetadata.content_key in enterprise-catalog." + ), + ) + is_assigned_course_run = models.BooleanField( + null=False, + blank=False, + default=False, + help_text=( + "Whether the content_key corresponds to a course run. If True, the content_key should be a course run key." + ), + ) content_title = models.CharField( max_length=255, blank=True, From 5a4bb19513c680f8f1084e5eba8b45c622741fde Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 4 Sep 2024 14:42:01 -0400 Subject: [PATCH 4/6] Chore: adding Datadog monitoring middleware (#548) * chore: adding datadog monitoring middleware * style: fix comment --- enterprise_access/settings/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 701b2391..f6a7fbd6 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -83,6 +83,7 @@ def root(*path_fragments): 'log_request_id.middleware.RequestIDMiddleware', # Resets RequestCache utility for added safety. 'edx_django_utils.cache.middleware.RequestCacheMiddleware', + 'edx_django_utils.monitoring.DeploymentMonitoringMiddleware', # Enables monitoring utility for writing custom metrics. 'edx_django_utils.monitoring.middleware.MonitoringCustomMetricsMiddleware', 'corsheaders.middleware.CorsMiddleware', @@ -100,7 +101,7 @@ def root(*path_fragments): # Enables force_django_cache_miss functionality for TieredCache. 'edx_django_utils.cache.middleware.TieredCacheMiddleware', # Outputs monitoring metrics for a request. - 'edx_rest_framework_extensions.middleware.RequestMetricsMiddleware', + 'edx_rest_framework_extensions.middleware.RequestCustomAttributesMiddleware', # Ensures proper DRF permissions in support of JWTs 'edx_rest_framework_extensions.auth.jwt.middleware.EnsureJWTAuthSettingsMiddleware', # Track who made each change to a model using HistoryRequestMiddleware From d4b359b4d4bde2f0c388478a396d3a0857c74412 Mon Sep 17 00:00:00 2001 From: Alexander Dusenbery Date: Wed, 4 Sep 2024 11:22:23 -0400 Subject: [PATCH 5/6] feat: failed notifications for assignments leave state as allocated When the assignment email notification task fails, the corresponding assignment is now NOT moved to an errored state, but instead left in an allocated state. Furthermore, the admin-serializer for the assignment now indicates a "failed" _learner_ state, and an error reason related to the failed notification attempt. ENT-9037 --- .../content_assignments/assignment.py | 8 +- .../api/v1/tests/test_assignment_views.py | 48 ++++++++ .../apps/content_assignments/models.py | 14 +++ .../apps/content_assignments/tasks.py | 8 ++ .../content_assignments/tests/test_tasks.py | 111 +++++++++++++----- 5 files changed, 158 insertions(+), 31 deletions(-) diff --git a/enterprise_access/apps/api/serializers/content_assignments/assignment.py b/enterprise_access/apps/api/serializers/content_assignments/assignment.py index f7bb91c5..9711bd69 100644 --- a/enterprise_access/apps/api/serializers/content_assignments/assignment.py +++ b/enterprise_access/apps/api/serializers/content_assignments/assignment.py @@ -186,8 +186,12 @@ def get_error_reason(self, assignment): Resolves the error reason for the assignment, if any, for display purposes based on any associated actions. """ - # If the assignment is not in an errored state, there should be no error reason. - if assignment.state != LearnerContentAssignmentStateChoices.ERRORED: + # If the assignment is not in an errored or allocated state, + # there should be no error reason. + if assignment.state not in ( + LearnerContentAssignmentStateChoices.ERRORED, + LearnerContentAssignmentStateChoices.ALLOCATED, + ): return None # Assignment is an errored state, so determine the appropriate error reason so clients don't need to. 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 ffcd5223..b07a9810 100644 --- a/enterprise_access/apps/api/v1/tests/test_assignment_views.py +++ b/enterprise_access/apps/api/v1/tests/test_assignment_views.py @@ -12,6 +12,7 @@ from enterprise_access.apps.content_assignments.constants import ( NUM_DAYS_BEFORE_AUTO_EXPIRATION, + AssignmentActionErrors, AssignmentActions, AssignmentAutomaticExpiredReason, AssignmentLearnerStates, @@ -467,6 +468,53 @@ def test_retrieve(self, role_context_dict, mock_subsidy_record, mock_catalog_cli }, } + @ddt.data( + # A good admin role, and with a context matching the main testing customer. + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + # A good operator role, and with a context matching the main testing customer. + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ) + @mock.patch('enterprise_access.apps.content_metadata.api.EnterpriseCatalogApiClient', autospec=True) + @mock.patch.object(SubsidyAccessPolicy, 'subsidy_record', autospec=True) + def test_retrieve_allocated_with_notification_error( + self, role_context_dict, mock_subsidy_record, mock_catalog_client + ): + assignment = LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ALLOCATED, + lms_user_id=98123, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + content_key='edX+edXPrivacy101', + content_quantity=-321, + content_title='edx: Privacy 101' + ) + assignment.add_errored_notified_action(Exception('foo')) + + # Set the JWT-based auth that we'll use for every request. + self.set_jwt_cookie([role_context_dict]) + + # Mock results from the catalog content metadata API endpoint. + mock_catalog_client.return_value.catalog_content_metadata.return_value = self.mock_catalog_result + + # Mock results from the subsidy record. + mock_subsidy_record.return_value = self.mock_subsidy_record + + # Setup and call the retrieve endpoint. + detail_kwargs = { + 'assignment_configuration_uuid': str(TEST_ASSIGNMENT_CONFIG_UUID), + 'uuid': str(assignment.uuid), + } + detail_url = reverse('api:v1:admin-assignments-detail', kwargs=detail_kwargs) + response = self.client.get(detail_url) + + assert response.status_code == status.HTTP_200_OK + assert response.json().get('state') == LearnerContentAssignmentStateChoices.ALLOCATED + assert response.json().get('learner_state') == AssignmentLearnerStates.FAILED + assert response.json().get('error_reason') == { + 'action_type': AssignmentActions.NOTIFIED, + 'error_reason': AssignmentActionErrors.EMAIL_ERROR, + } + @ddt.data( # A good admin role, and with a context matching the main testing customer. {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, diff --git a/enterprise_access/apps/content_assignments/models.py b/enterprise_access/apps/content_assignments/models.py index c7b18f78..9f61b8a0 100644 --- a/enterprise_access/apps/content_assignments/models.py +++ b/enterprise_access/apps/content_assignments/models.py @@ -802,9 +802,23 @@ def annotate_dynamic_fields_onto_queryset(cls, queryset): error_reason__isnull=True, completed_at__isnull=False, ) + ), + # ... or if they have an errored notification. + has_errored_notification=Exists( + LearnerContentAssignmentAction.objects.filter( + assignment=OuterRef('uuid'), + action_type=AssignmentActions.NOTIFIED, + error_reason__isnull=False, + ) ) ).annotate( learner_state=Case( + When( + Q(state=LearnerContentAssignmentStateChoices.ALLOCATED) & + Q(has_errored_notification=True) & + Q(has_notification=False), + then=Value(AssignmentLearnerStates.FAILED), + ), When( Q(state=LearnerContentAssignmentStateChoices.ALLOCATED) & Q(has_notification=False), then=Value(AssignmentLearnerStates.NOTIFYING), diff --git a/enterprise_access/apps/content_assignments/tasks.py b/enterprise_access/apps/content_assignments/tasks.py index 0ae95208..2e2a1bff 100644 --- a/enterprise_access/apps/content_assignments/tasks.py +++ b/enterprise_access/apps/content_assignments/tasks.py @@ -458,6 +458,14 @@ class SendNotificationEmailTask(BaseAssignmentRetryAndErrorActionTask): def add_errored_action(self, assignment, exc): assignment.add_errored_notified_action(exc) + def progress_state_on_failure(self, assignment): + """ + Skip progressing the assignment state to `failed` (keeping it `allocated`) + so that the assignment remains functional and redeemable + for learners and appears as actionable to admins. + """ + logger.info('NOT progressing the assignment state to failed for notification failures.') + @shared_task(base=SendNotificationEmailTask) def send_email_for_new_assignment(new_assignment_uuid): diff --git a/enterprise_access/apps/content_assignments/tests/test_tasks.py b/enterprise_access/apps/content_assignments/tests/test_tasks.py index c8f2ed5b..8e037799 100644 --- a/enterprise_access/apps/content_assignments/tests/test_tasks.py +++ b/enterprise_access/apps/content_assignments/tests/test_tasks.py @@ -39,6 +39,8 @@ from enterprise_access.utils import get_automatic_expiration_date_and_reason from test_utils import APITestWithMocks +TEST_CONTENT_KEY = 'course:test_content' +TEST_ENTERPRISE_CUSTOMER_NAME = 'test-customer-name' TEST_ENTERPRISE_UUID = uuid4() TEST_EMAIL = 'foo@bar.com' TEST_LMS_USER_ID = 2 @@ -215,13 +217,37 @@ def setUpTestData(cls): assignment_configuration=cls.assignment_configuration, spend_limit=10000000, ) + cls.mock_enterprise_customer_data = { + 'uuid': TEST_ENTERPRISE_UUID, + 'slug': 'test-slug', + 'admin_users': [{ + 'email': 'test@admin.com', + 'lms_user_id': 1 + }], + 'name': TEST_ENTERPRISE_CUSTOMER_NAME, + } + cls.mock_content_metadata = { + 'key': TEST_CONTENT_KEY, + 'normalized_metadata': { + 'start_date': '2020-01-01T12:00:00Z', + 'end_date': '2022-01-01 12:00:00Z', + 'enroll_by_date': '2021-01-01T12:00:00Z', + 'content_price': 123, + }, + 'owners': [ + {'name': 'Smart Folks', 'logo_image_url': 'http://pictures.yes'}, + {'name': 'Good People', 'logo_image_url': 'http://pictures.nice'}, + ], + 'card_image_url': 'https://itsanimage.com', + } def setUp(self): super().setUp() self.course_name = 'test-course-name' - self.enterprise_customer_name = 'test-customer-name' + self.enterprise_customer_name = TEST_ENTERPRISE_CUSTOMER_NAME self.assignment = LearnerContentAssignmentFactory( uuid=TEST_ASSIGNMENT_UUID, + content_key=TEST_CONTENT_KEY, learner_email='TESTING THIS EMAIL', lms_user_id=TEST_LMS_USER_ID, assignment_configuration=self.assignment_configuration, @@ -386,36 +412,13 @@ def test_send_email_for_new_assignment( """ Verify send_email_for_new_assignment hits braze client with expected args """ - admin_email = 'test@admin.com' - mock_lms_client.return_value.get_enterprise_customer_data.return_value = { - 'uuid': TEST_ENTERPRISE_UUID, - 'slug': 'test-slug', - 'admin_users': [{ - 'email': admin_email, - 'lms_user_id': 1 - }], - 'name': self.enterprise_customer_name, - } + mock_lms_client.return_value.get_enterprise_customer_data.return_value = self.mock_enterprise_customer_data mock_recipient = { 'external_user_id': 1 } - mock_metadata = { - 'key': self.assignment.content_key, - 'normalized_metadata': { - 'start_date': '2020-01-01T12:00:00Z', - 'end_date': '2022-01-01 12:00:00Z', - 'enroll_by_date': '2021-01-01T12:00:00Z', - 'content_price': 123, - }, - 'owners': [ - {'name': 'Smart Folks', 'logo_image_url': 'http://pictures.yes'}, - {'name': 'Good People', 'logo_image_url': 'http://pictures.nice'}, - ], - 'card_image_url': 'https://itsanimage.com', - } mock_catalog_client.return_value.catalog_content_metadata.return_value = { 'count': 1, - 'results': [mock_metadata] + 'results': [self.mock_content_metadata] } # Set the subsidy expiration time to tomorrow @@ -425,7 +428,8 @@ def test_send_email_for_new_assignment( } mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy - mock_admin_mailto = f'mailto:{admin_email}' + admin_email = self.mock_enterprise_customer_data['admin_users'][0]['email'] + mock_admin_mailto = f"mailto:{admin_email}" mock_braze_client.return_value.create_recipient.return_value = mock_recipient mock_braze_client.return_value.generate_mailto_link.return_value = mock_admin_mailto @@ -446,13 +450,62 @@ def test_send_email_for_new_assignment( 'enrollment_deadline': 'Jan 01, 2021', 'start_date': 'Jan 01, 2020', 'course_partner': 'Smart Folks and Good People', - 'course_card_image': 'https://itsanimage.com', - 'learner_portal_link': '{}/{}'.format(settings.ENTERPRISE_LEARNER_PORTAL_URL, 'test-slug'), + 'course_card_image': self.mock_content_metadata['card_image_url'], + 'learner_portal_link': '{}/{}'.format( + settings.ENTERPRISE_LEARNER_PORTAL_URL, + self.mock_enterprise_customer_data['slug'] + ), 'action_required_by_timestamp': '2021-01-01T12:00:00Z' }, ) assert mock_braze_client.return_value.send_campaign_message.call_count == 1 + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client') + @mock.patch('enterprise_access.apps.content_metadata.api.EnterpriseCatalogApiClient') + @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') + @mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient') + def test_send_email_for_new_assignment_failure( + self, + mock_braze_client, + mock_lms_client, + mock_catalog_client, + mock_subsidy_client, + ): + """ + Verify send_email_for_new_assignment does not change the state of the + assignment record on failure. + """ + mock_lms_client.return_value.get_enterprise_customer_data.return_value = self.mock_enterprise_customer_data + mock_recipient = { + 'external_user_id': 1 + } + mock_catalog_client.return_value.catalog_content_metadata.return_value = { + 'count': 1, + 'results': [self.mock_content_metadata] + } + + # Set the subsidy expiration time to tomorrow + mock_subsidy = { + 'uuid': self.policy.subsidy_uuid, + 'expiration_datetime': (now() + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%SZ'), + } + mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + + braze_client_instance = mock_braze_client.return_value + braze_client_instance.send_campaign_message.side_effect = Exception('foo') + + admin_email = self.mock_enterprise_customer_data['admin_users'][0]['email'] + mock_admin_mailto = f"mailto:{admin_email}" + braze_client_instance.create_recipient.return_value = mock_recipient + braze_client_instance.generate_mailto_link.return_value = mock_admin_mailto + + send_email_for_new_assignment.delay(self.assignment.uuid) + self.assertEqual(self.assignment.state, LearnerContentAssignmentStateChoices.ALLOCATED) + self.assertTrue(self.assignment.actions.filter( + error_reason=AssignmentActionErrors.EMAIL_ERROR, + action_type=AssignmentActions.NOTIFIED, + ).exists()) + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.objects') @mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient') @mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient') From 53875bd5904f4abfab2c30972b1082b4bd5bd361 Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:25:54 -0400 Subject: [PATCH 6/6] chore: enable github action auto update in dependabot.yml (#553) --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..9186f742 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + # Adding new check for github-actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly"