From 106b52bb8632ef691ae3b884e280ab2a4175eef6 Mon Sep 17 00:00:00 2001 From: Alexander Dusenbery Date: Fri, 22 Nov 2024 11:45:03 -0500 Subject: [PATCH] feat: actualize the default enrollments ENT-9632 --- .../apps/api_client/lms_client.py | 64 +++++++++++++++ .../apps/api_client/tests/test_lms_client.py | 82 +++++++++++++++++++ enterprise_access/apps/bffs/handlers.py | 74 +++++++++++------ enterprise_access/apps/bffs/serializers.py | 6 +- 4 files changed, 199 insertions(+), 27 deletions(-) diff --git a/enterprise_access/apps/api_client/lms_client.py b/enterprise_access/apps/api_client/lms_client.py index 8949c8bd..cdf5998b 100755 --- a/enterprise_access/apps/api_client/lms_client.py +++ b/enterprise_access/apps/api_client/lms_client.py @@ -51,6 +51,12 @@ class LmsApiClient(BaseOAuthClient): pending_enterprise_learner_endpoint = enterprise_api_base_url + 'pending-enterprise-learner/' enterprise_group_membership_endpoint = enterprise_api_base_url + 'enterprise-group/' + def enterprise_customer_url(self, enterprise_customer_uuid): + return os.path.join( + self.enterprise_customer_endpoint, + f"{enterprise_customer_uuid}/", + ) + def enterprise_group_endpoint(self, group_uuid): return os.path.join( self.enterprise_api_base_url + 'enterprise-group/', @@ -63,6 +69,12 @@ def enterprise_group_members_endpoint(self, group_uuid): "learners/", ) + def enterprise_customer_bulk_enrollment_url(self, enterprise_customer_uuid): + return os.path.join( + self.enterprise_customer_url(enterprise_customer_uuid), + "enroll_learners_in_courses/", + ) + def get_enterprise_customer_data(self, enterprise_customer_uuid=None, enterprise_customer_slug=None): """ Gets the data for an EnterpriseCustomer for the given uuid or slug. @@ -408,6 +420,58 @@ def update_pending_learner_status(self, enterprise_group_uuid, learner_email): logger.exception('failed to update group membership status. [%s]', url) return None + def bulk_enroll_enterprise_learners(self, enterprise_customer_uuid, enrollments_info): + """ + Calls the Enterprise Bulk Enrollment API to enroll learners in courses. + + Arguments: + enterprise_customer_uuid (UUID): UUID representation of the customer that the enrollment will be linked to + enrollment_info (list[dicts]): List of enrollment information required to enroll. + Each entry must contain key/value pairs as follows: + user_id: ID of the learner to be enrolled + course_run_key: the course run key to be enrolled in by the user + [transaction_id,license_uuid]: uuid representation of the subsidy identifier + that allows the enrollment + is_default_auto_enrollment (optional): boolean indicating whether the enrollment + is the realization of a default enrollment intention. + Example:: + [ + { + 'user_id': 1234, + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'transaction_id': '84kdbdbade7b4fcb838f8asjke8e18ae', + }, + { + 'user_id': 1234, + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'license_uuid': '00001111de7b4fcb838f8asjke8effff', + 'is_default_auto_enrollment': True, + }, + ... + ] + Returns: + response (dict): JSON response data + Raises: + requests.exceptions.HTTPError: if service is down/unavailable or status code comes back >= 300, + the method will log and throw an HTTPError exception. + """ + bulk_enrollment_url = self.enterprise_customer_bulk_enrollment_url(enterprise_customer_uuid) + options = {'enrollments_info': enrollments_info} + response = self.client.post( + bulk_enrollment_url, + json=options, + ) + try: + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as exc: + logger.error( + f'Failed to generate enterprise enrollments for enterprise: {enterprise_customer_uuid} ' + f'with options: {options}. Failed with error: {exc} and payload %s', + response.json(), + ) + raise exc + class LmsUserApiClient(BaseUserApiClient): """ diff --git a/enterprise_access/apps/api_client/tests/test_lms_client.py b/enterprise_access/apps/api_client/tests/test_lms_client.py index 048aab50..ab7390c9 100644 --- a/enterprise_access/apps/api_client/tests/test_lms_client.py +++ b/enterprise_access/apps/api_client/tests/test_lms_client.py @@ -333,6 +333,88 @@ def test_get_pending_enterprise_group_memberships(self, mock_oauth_client, mock_ ) assert pending_enterprise_group_memberships == expected_return + @mock.patch('requests.Response.json') + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') + def test_bulk_enroll_enterprise_learners(self, mock_oauth_client, mock_json): + """ + Tests that the ``bulk_enroll_enterprise_learners`` endpoint can be + requested via the LmsApiClient. + """ + mock_oauth_client.return_value.post.return_value = requests.Response() + mock_oauth_client.return_value.post.return_value.status_code = 200 + + mock_result = { + 'successes': [{'what': 'ever'}], + 'failures': [], + } + mock_json.return_value = mock_result + + enrollments_info = [ + { + 'user_id': 1234, + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'transaction_id': '84kdbdbade7b4fcb838f8asjke8e18ae', + }, + { + 'user_id': 1234, + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'license_uuid': '00001111de7b4fcb838f8asjke8effff', + 'is_default_auto_enrollment': True, + }, + ] + + client = LmsApiClient() + response_payload = client.bulk_enroll_enterprise_learners( + str(TEST_ENTERPRISE_UUID), + enrollments_info, + ) + + url = ( + 'http://edx-platform.example.com/enterprise/api/v1/enterprise-customer/' + f'{TEST_ENTERPRISE_UUID}/enroll_learners_in_courses/' + ) + mock_oauth_client.return_value.post.assert_called_with( + url, + json={'enrollments_info': enrollments_info}, + ) + self.assertEqual(response_payload, mock_result) + + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') + def test_bulk_enroll_enterprise_learners_exception(self, mock_oauth_client): + """ + Tests that the ``bulk_enroll_enterprise_learners`` endpoint can be + requested via the LmsApiClient, and errors are raised to the caller. + """ + mock_oauth_client.return_value.post.return_value = MockResponse( + {'detail': 'Bad Request'}, + status.HTTP_400_BAD_REQUEST, + ) + + enrollments_info = [ + { + 'user_id': 1234, + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'transaction_id': '84kdbdbade7b4fcb838f8asjke8e18ae', + }, + ] + + client = LmsApiClient() + + with self.assertRaises(requests.exceptions.HTTPError): + client.bulk_enroll_enterprise_learners( + str(TEST_ENTERPRISE_UUID), + enrollments_info, + ) + + url = ( + 'http://edx-platform.example.com/enterprise/api/v1/enterprise-customer/' + f'{TEST_ENTERPRISE_UUID}/enroll_learners_in_courses/' + ) + mock_oauth_client.return_value.post.assert_called_with( + url, + json={'enrollments_info': enrollments_info}, + ) + class TestLmsUserApiClient(TestCase): """ diff --git a/enterprise_access/apps/bffs/handlers.py b/enterprise_access/apps/bffs/handlers.py index 58402f92..561df15d 100644 --- a/enterprise_access/apps/bffs/handlers.py +++ b/enterprise_access/apps/bffs/handlers.py @@ -1,11 +1,11 @@ """" Handlers for bffs app. """ - +import json import logging from enterprise_access.apps.api_client.license_manager_client import LicenseManagerUserApiClient -from enterprise_access.apps.api_client.lms_client import LmsUserApiClient +from enterprise_access.apps.api_client.lms_client import LmsApiClient, LmsUserApiClient from enterprise_access.apps.bffs.context import HandlerContext from enterprise_access.apps.bffs.mixins import BaseLearnerDataMixin from enterprise_access.apps.bffs.serializers import EnterpriseCustomerUserSubsidiesSerializer @@ -408,38 +408,62 @@ def enroll_in_redeemable_default_enterprise_enrollment_intentions(self): """ Enroll in redeemable courses. """ - needs_enrollment = self.default_enterprise_enrollment_intentions.get('needs_enrollment', {}) + enrollment_statuses = self.default_enterprise_enrollment_intentions.get('enrollment_statuses', {}) + needs_enrollment = enrollment_statuses.get('needs_enrollment', {}) needs_enrollment_enrollable = needs_enrollment.get('enrollable', []) - activated_subscription_licenses = self.subscription_licenses_by_status.get('activated', []) - - if not (needs_enrollment_enrollable or activated_subscription_licenses): - # Skip enrollment if there are no: - # - default enterprise enrollment intentions that should be enrolled OR - # - activated subscription licenses + if not (needs_enrollment_enrollable and self.current_active_license): return - redeemable_default_courses = [] + license_uuids_by_course_run_key = {} for enrollment_intention in needs_enrollment_enrollable: - for subscription_license in activated_subscription_licenses: - subscription_plan = subscription_license.get('subscription_plan', {}) - subscription_catalog = subscription_plan.get('enterprise_catalog_uuid') - applicable_catalog_to_enrollment_intention = enrollment_intention.get( - 'applicable_enterprise_catalog_uuids' - ) - if subscription_catalog in applicable_catalog_to_enrollment_intention: - redeemable_default_courses.append((enrollment_intention, subscription_license)) - break + subscription_plan = self.current_active_license.get('subscription_plan', {}) + subscription_catalog = subscription_plan.get('enterprise_catalog_uuid') + applicable_catalog_to_enrollment_intention = enrollment_intention.get( + 'applicable_enterprise_catalog_uuids' + ) + if subscription_catalog in applicable_catalog_to_enrollment_intention: + course_run_key = enrollment_intention['course_run_key'] + license_uuids_by_course_run_key[course_run_key] = self.current_active_license['uuid'] + break + + bulk_enrollment_payload = [] + for course_run_key, license_uuid in license_uuids_by_course_run_key.items(): + bulk_enrollment_payload.append({ + 'user_id': self.context.lms_user_id, + 'course_run_key': course_run_key, + 'license_uuid': license_uuid, + 'is_default_auto_enrollment': True, + }) + + client = LmsApiClient() + try: + response_payload = client.bulk_enroll_enterprise_learners( + self.context.enterprise_customer_uuid, + bulk_enrollment_payload, + ) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.exception('Error actualizing default enrollments') + self.add_error( + user_message='There was an exception realizing default enrollments', + developer_message=f'Default realization enrollment exception: {exc}', + ) + + if failures := response_payload.get('failures'): + self.add_error( + user_message='There were failures realizing default enrollments', + developer_message='Default realization enrollment failures: ' + json.dumps(failures), + ) - for redeemable_course, subscription_license in redeemable_default_courses: - # TODO: enroll in redeemable courses (stubbed) - if not self.context.data.get('default_enterprise_enrollment_realizations'): - self.context.data['default_enterprise_enrollment_realizations'] = [] + if not self.context.data.get('default_enterprise_enrollment_realizations'): + self.context.data['default_enterprise_enrollment_realizations'] = [] + for enrollment in response_payload['successes']: + course_run_key = enrollment.get('course_run_key') self.context.data['default_enterprise_enrollment_realizations'].append({ - 'course_key': redeemable_course.get('key'), + 'course_key': course_run_key, 'enrollment_status': 'enrolled', - 'subscription_license_uuid': subscription_license.get('uuid'), + 'subscription_license_uuid': license_uuids_by_course_run_key.get(course_run_key), }) diff --git a/enterprise_access/apps/bffs/serializers.py b/enterprise_access/apps/bffs/serializers.py index 91dd076b..ff98ac85 100644 --- a/enterprise_access/apps/bffs/serializers.py +++ b/enterprise_access/apps/bffs/serializers.py @@ -175,7 +175,9 @@ class CustomerAgreementSerializer(BaseBffSerializer): disable_expiration_notifications = serializers.BooleanField() enable_auto_applied_subscriptions_with_universal_link = serializers.BooleanField() subscription_for_auto_applied_licenses = serializers.UUIDField(allow_null=True) - has_custom_license_expiration_messaging_v2 = serializers.BooleanField(required=False, default=False) + has_custom_license_expiration_messaging_v2 = serializers.BooleanField( + required=False, allow_null=True, default=False, + ) button_label_in_modal_v2 = serializers.CharField(required=False, allow_null=True) expired_subscription_modal_messaging_v2 = serializers.CharField(required=False, allow_null=True) modal_header_text_v2 = serializers.CharField(required=False, allow_null=True) @@ -261,7 +263,7 @@ class EnterpriseCourseEnrollmentSerializer(BaseBffSerializer): org_name = serializers.CharField() course_run_status = serializers.CharField() display_name = serializers.CharField() - emails_enabled = serializers.BooleanField() + emails_enabled = serializers.BooleanField(required=False, allow_null=True) certificate_download_url = serializers.CharField(allow_null=True) created = serializers.DateTimeField() start_date = serializers.DateTimeField(allow_null=True)