diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py index 44ea7dcc8f..7945ae511d 100644 --- a/common/djangoapps/enrollment/tests/test_views.py +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -20,11 +20,13 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range from django.test.utils import override_settings import pytz +import httpretty from course_modes.models import CourseMode from enrollment.views import EnrollmentUserThrottle from util.models import RateLimitConfiguration from util.testing import UrlResetMixin +from util.tests.mixins.enterprise import EnterpriseServiceMockMixin from enrollment import api from enrollment.errors import CourseEnrollmentError from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -53,6 +55,7 @@ class EnrollmentTestMixin(object): enrollment_attributes=None, min_mongo_calls=0, max_mongo_calls=0, + enterprise_course_consent=None, ): """ Enroll in the course and verify the response's status code. If the expected status is 200, also validates @@ -79,6 +82,9 @@ class EnrollmentTestMixin(object): if email_opt_in is not None: data['email_opt_in'] = email_opt_in + if enterprise_course_consent is not None: + data['enterprise_course_consent'] = enterprise_course_consent + extra = {} if as_server: extra['HTTP_X_EDX_API_KEY'] = self.API_KEY @@ -130,7 +136,7 @@ class EnrollmentTestMixin(object): @override_settings(EDX_API_KEY="i am a key") @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): +class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase, EnterpriseServiceMockMixin): """ Test user enrollment, especially with different course modes. """ @@ -924,6 +930,73 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): self.assertTrue(is_active) self.assertEqual(course_mode, CourseMode.DEFAULT_MODE_SLUG) + def test_enterprise_course_enrollment_invalid_consent(self): + """Verify that the enterprise_course_consent must be a boolean. """ + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=CourseMode.DEFAULT_MODE_SLUG, + mode_display_name=CourseMode.DEFAULT_MODE_SLUG, + ) + self.assert_enrollment_status( + expected_status=status.HTTP_400_BAD_REQUEST, + enterprise_course_consent='invalid', + as_server=True, + ) + + @httpretty.activate + @override_settings(ENTERPRISE_SERVICE_WORKER_USERNAME='enterprise_worker') + def test_enterprise_course_enrollment_api_error(self): + """Verify that enterprise service errors are handled properly. """ + UserFactory.create( + username='enterprise_worker', + email=self.EMAIL, + password=self.PASSWORD, + ) + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=CourseMode.DEFAULT_MODE_SLUG, + mode_display_name=CourseMode.DEFAULT_MODE_SLUG, + ) + self.mock_enterprise_course_enrollment_post_api_failure() + self.assert_enrollment_status( + expected_status=status.HTTP_400_BAD_REQUEST, + enterprise_course_consent=True, + as_server=True, + username='enterprise_worker' + ) + self.assertEqual( + httpretty.last_request().path, + '/enterprise/api/v1/enterprise-course-enrollment/', + 'No request was made to the mocked enterprise-course-enrollment API' + ) + + @httpretty.activate + @override_settings(ENTERPRISE_SERVICE_WORKER_USERNAME='enterprise_worker') + def test_enterprise_course_enrollment_successful(self): + """Verify that the enrollment completes when the EnterpriseCourseEnrollment creation succeeds. """ + UserFactory.create( + username='enterprise_worker', + email=self.EMAIL, + password=self.PASSWORD, + ) + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=CourseMode.DEFAULT_MODE_SLUG, + mode_display_name=CourseMode.DEFAULT_MODE_SLUG, + ) + self.mock_enterprise_course_enrollment_post_api(username=self.user.username, course_id=unicode(self.course.id)) + self.assert_enrollment_status( + expected_status=status.HTTP_200_OK, + enterprise_course_consent=True, + as_server=True, + username='enterprise_worker' + ) + self.assertEqual( + httpretty.last_request().path, + '/enterprise/api/v1/enterprise-course-enrollment/', + 'No request was made to the mocked enterprise-course-enrollment API' + ) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestCase): diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index f510d66024..a4d623361e 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -16,8 +16,6 @@ from rest_framework.throttling import UserRateThrottle from rest_framework.views import APIView from course_modes.models import CourseMode -from enrollment import api -from enrollment.errors import CourseEnrollmentError, CourseModeNotFoundError, CourseEnrollmentExistsError from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain from openedx.core.djangoapps.embargo import api as embargo_api @@ -28,6 +26,13 @@ from openedx.core.lib.api.authentication import ( from openedx.core.lib.api.permissions import ApiKeyHeaderPermission, ApiKeyHeaderPermissionIsAuthenticated from openedx.core.lib.exceptions import CourseNotFoundError from openedx.core.lib.log_utils import audit_log +from util.enterprise_helpers import enterprise_enabled, EnterpriseApiClient, EnterpriseApiException +from enrollment import api +from enrollment.errors import ( + CourseEnrollmentError, + CourseModeNotFoundError, + CourseEnrollmentExistsError +) from student.auth import user_has_role from student.models import User from student.roles import CourseStaffRole, GlobalStaff @@ -362,6 +367,10 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): * user: Optional. The user ID of the currently logged in user. You cannot use the command to enroll a different user. + * enterprise_course_consent: Optional. A Boolean value that + indicates the consent status for an EnterpriseCourseEnrollment + to be posted to the Enterprise service. + **GET Response Values** If an unspecified error occurs when the user tries to obtain a @@ -574,6 +583,29 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): } ) + enterprise_course_consent = request.data.get('enterprise_course_consent') + # Check if the enterprise_course_enrollment is a boolean + if has_api_key_permissions and enterprise_enabled() and enterprise_course_consent is not None: + if not isinstance(enterprise_course_consent, bool): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + 'message': (u"'{value}' is an invalid enterprise course consent value.").format( + value=enterprise_course_consent + ) + } + ) + try: + EnterpriseApiClient().post_enterprise_course_enrollment( + username, + unicode(course_id), + enterprise_course_consent + ) + except EnterpriseApiException as error: + log.exception("An unexpected error occurred while creating the new EnterpriseCourseEnrollment " + "for user [%s] in course run [%s]", username, course_id) + raise CourseEnrollmentError(error.message) + enrollment_attributes = request.data.get('enrollment_attributes') enrollment = api.get_enrollment(username, unicode(course_id)) mode_changed = enrollment and mode is not None and enrollment['mode'] != mode diff --git a/common/djangoapps/util/enterprise_helpers.py b/common/djangoapps/util/enterprise_helpers.py index 7fbcfe1faf..14dade33ce 100644 --- a/common/djangoapps/util/enterprise_helpers.py +++ b/common/djangoapps/util/enterprise_helpers.py @@ -1,31 +1,75 @@ """ Helpers to access the enterprise app """ -from django.conf import settings -from django.core.urlresolvers import reverse -from django.utils.translation import ugettext as _ import logging +from django.conf import settings +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse from django.utils.http import urlencode - +from edx_rest_api_client.client import EdxRestApiClient try: - from enterprise.models import EnterpriseCustomer from enterprise import utils as enterprise_utils - from enterprise.tpa_pipeline import ( - active_provider_requests_data_sharing, - active_provider_enforces_data_sharing, - get_enterprise_customer_for_request, - ) from enterprise.utils import consent_necessary_for_course - except ImportError: pass - from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.lib.token_utils import JwtBuilder +from slumber.exceptions import HttpClientError, HttpServerError + + ENTERPRISE_CUSTOMER_BRANDING_OVERRIDE_DETAILS = 'enterprise_customer_branding_override_details' LOGGER = logging.getLogger("edx.enterprise_helpers") +class EnterpriseApiException(Exception): + """ + Exception for errors while communicating with the Enterprise service API. + """ + pass + + +class EnterpriseApiClient(object): + """ + Class for producing an Enterprise service API client. + """ + + def __init__(self): + """ + Initialize an Enterprise service API client, authenticated using the Enterprise worker username. + """ + self.user = User.objects.get(username=settings.ENTERPRISE_SERVICE_WORKER_USERNAME) + jwt = JwtBuilder(self.user).build_token([]) + self.client = EdxRestApiClient( + configuration_helpers.get_value('ENTERPRISE_API_URL', settings.ENTERPRISE_API_URL), + jwt=jwt + ) + + def post_enterprise_course_enrollment(self, username, course_id, consent_granted): + """ + Create an EnterpriseCourseEnrollment by using the corresponding serializer (for validation). + """ + data = { + 'username': username, + 'course_id': course_id, + 'consent_granted': consent_granted, + } + endpoint = getattr(self.client, 'enterprise-course-enrollment') # pylint: disable=literal-used-as-attribute + try: + endpoint.post(data=data) + except (HttpClientError, HttpServerError): + message = ( + "An error occured while posting EnterpriseCourseEnrollment for user {username} and " + "course run {course_id} (consent_granted value: {consent_granted})" + ).format( + username=username, + course_id=course_id, + consent_granted=consent_granted, + ) + LOGGER.exception(message) + raise EnterpriseApiException(message) + + def enterprise_enabled(): """ Determines whether the Enterprise app is installed diff --git a/common/djangoapps/util/tests/mixins/__init__.py b/common/djangoapps/util/tests/mixins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/util/tests/mixins/enterprise.py b/common/djangoapps/util/tests/mixins/enterprise.py new file mode 100644 index 0000000000..de8342e210 --- /dev/null +++ b/common/djangoapps/util/tests/mixins/enterprise.py @@ -0,0 +1,57 @@ +""" +Mixins for the EnterpriseApiClient. +""" +import json + +import httpretty +from django.conf import settings +from django.core.cache import cache + + +class EnterpriseServiceMockMixin(object): + """ + Mocks for the Enterprise service responses. + """ + + def setUp(self): + super(EnterpriseServiceMockMixin, self).setUp() + cache.clear() + + @staticmethod + def get_enterprise_url(path): + """Return a URL to the configured Enterprise API. """ + return '{}{}/'.format(settings.ENTERPRISE_API_URL, path) + + def mock_enterprise_course_enrollment_post_api( # pylint: disable=invalid-name + self, + username='test_user', + course_id='course-v1:edX+DemoX+Demo_Course', + consent_granted=True + ): + """ + Helper method to register the enterprise course enrollment API POST endpoint. + """ + api_response = { + username: username, + course_id: course_id, + consent_granted: consent_granted, + } + api_response_json = json.dumps(api_response) + httpretty.register_uri( + method=httpretty.POST, + uri=self.get_enterprise_url('enterprise-course-enrollment'), + body=api_response_json, + content_type='application/json' + ) + + def mock_enterprise_course_enrollment_post_api_failure(self): # pylint: disable=invalid-name + """ + Helper method to register the enterprise course enrollment API endpoint for a failure. + """ + httpretty.register_uri( + method=httpretty.POST, + uri=self.get_enterprise_url('enterprise-course-enrollment'), + body='{}', + content_type='application/json', + status=500 + ) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 0ed14eda75..3331fc93bc 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -176,6 +176,7 @@ EDXMKTG_LOGGED_IN_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_LOGGED_IN_COOKIE_NAME', EDXMKTG_USER_INFO_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_USER_INFO_COOKIE_NAME', EDXMKTG_USER_INFO_COOKIE_NAME) LMS_ROOT_URL = ENV_TOKENS.get('LMS_ROOT_URL') +ENTERPRISE_API_URL = ENV_TOKENS.get('ENTERPRISE_API_URL', LMS_ROOT_URL + '/enterprise/api/v1/') ENV_FEATURES = ENV_TOKENS.get('FEATURES', {}) for feature, value in ENV_FEATURES.items(): diff --git a/lms/envs/common.py b/lms/envs/common.py index 832bb30d62..5eefc3ea78 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -61,6 +61,7 @@ DISCUSSION_SETTINGS = { } LMS_ROOT_URL = "http://localhost:8000" +ENTERPRISE_API_URL = LMS_ROOT_URL + '/enterprise/api/v1/' # Features FEATURES = { diff --git a/lms/envs/devstack_docker.py b/lms/envs/devstack_docker.py index 4db45d7168..58198be3ac 100644 --- a/lms/envs/devstack_docker.py +++ b/lms/envs/devstack_docker.py @@ -15,7 +15,7 @@ LMS_ROOT_URL = 'http://{}'.format(HOST) ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:18130' ECOMMERCE_API_URL = 'http://edx.devstack.ecommerce:18130/api/v2' - +ENTERPRISE_API_URL = 'http://enterprise.example.com/enterprise/api/v1/' OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL) diff --git a/lms/envs/test.py b/lms/envs/test.py index 8075dfca0f..1d06868392 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -590,3 +590,4 @@ COMPREHENSIVE_THEME_LOCALE_PATHS = [REPO_ROOT / "themes/conf/locale", ] LMS_ROOT_URL = "http://localhost:8000" ECOMMERCE_API_URL = 'https://ecommerce.example.com/api/v2/' +ENTERPRISE_API_URL = 'http://enterprise.example.com/enterprise/api/v1/' diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 4379ac018d..f8cdc5516a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -51,7 +51,7 @@ edx-lint==0.4.3 astroid==1.3.8 edx-django-oauth2-provider==1.1.4 edx-django-sites-extensions==2.1.1 -edx-enterprise==0.22.0 +edx-enterprise==0.23.0 edx-oauth2-provider==1.2.0 edx-opaque-keys==0.4.0 edx-organizations==0.4.3