From d940bbfd09db927f634c25ead226e965a5a8a910 Mon Sep 17 00:00:00 2001 From: Jesse Shapiro <jesse@opencraft.com> Date: Fri, 30 Jun 2017 12:39:14 -0400 Subject: [PATCH] Create EnterpriseCourseEnrollment when enrolling via Track Selection page --- .../course_modes/tests/test_views.py | 89 +++++++++++++++++++ common/djangoapps/course_modes/views.py | 58 ++++++++++-- openedx/features/enterprise_support/api.py | 35 +++++++- .../tests/mixins/enterprise.py | 12 +++ .../enterprise_support/tests/test_api.py | 23 +++++ 5 files changed, 208 insertions(+), 9 deletions(-) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 81daf6020f7..c3940ddbf06 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -232,6 +232,52 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest self.assertContains(response, 'Pursue a Verified Certificate') self.assertContains(response, 'Audit This Course') + @httpretty.activate + @patch('course_modes.views.get_enterprise_consent_url') + @ddt.data( + (True, True), + (True, False), + (False, True), + (False, False), + ) + @ddt.unpack + def test_enterprise_course_enrollment_creation( + self, + enterprise_enrollment_exists, + course_in_catalog, + get_consent_url_mock, + ): + for mode in ('audit', 'honor', 'verified'): + CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) + + catalog_integration = self.create_catalog_integration() + UserFactory(username=catalog_integration.service_username) + + courses_in_catalog = [str(self.course.id)] if course_in_catalog else [] + enterprise_enrollment = {'course_id': str(self.course.id)} if enterprise_enrollment_exists else {} + + self.mock_course_discovery_api_for_catalog_contains( + catalog_id=1, course_run_ids=courses_in_catalog + ) + self.mock_enterprise_course_enrollment_get_api(**enterprise_enrollment) + self.mock_enterprise_course_enrollment_post_api() + self.mock_enterprise_learner_api(enable_audit_enrollment=True) + + get_consent_url_mock.return_value = 'http://appropriate-consent-url.com/' + + url = reverse('course_modes_choose', args=[unicode(self.course.id)]) + + response = self.client.post(url, self.POST_PARAMS_FOR_COURSE_MODE['audit']) + + final_url = reverse('dashboard') if not course_in_catalog else 'http://appropriate-consent-url.com/' + + self.assertRedirects(response, final_url, fetch_redirect_response=False) + if course_in_catalog: + if enterprise_enrollment_exists: + self.assertEquals(httpretty.last_request().method, 'GET') + else: + self.assertEquals(httpretty.last_request().method, 'POST') + @httpretty.activate @ddt.data( '', @@ -330,6 +376,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest 'unsupported': {'unsupported_mode': True}, } + @httpretty.activate @ddt.data( ('audit', 'dashboard'), ('honor', 'dashboard'), @@ -337,6 +384,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest ) @ddt.unpack def test_choose_mode_redirect(self, course_mode, expected_redirect): + self.mock_enterprise_learner_api() + self.mock_enterprise_course_enrollment_get_api() # Create the course modes for mode in ('audit', 'honor', 'verified'): min_price = 0 if mode in ["honor", "audit"] else 1 @@ -359,7 +408,38 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest self.assertRedirects(response, redirect_url) + @httpretty.activate + def test_choose_mode_audit_enroll_on_get(self): + """ + Confirms that the learner will be enrolled in Audit track if it is the only possible option + """ + self.mock_enterprise_learner_api() + self.mock_enterprise_course_enrollment_get_api() + # Create the course mode + audit_mode = 'audit' + CourseModeFactory.create(mode_slug=audit_mode, course_id=self.course.id, min_price=0) + + # Assert learner is not enrolled in Audit track pre-POST + mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertIsNone(mode) + self.assertIsNone(is_active) + + # Choose the audit mode (POST request) + choose_track_url = reverse('course_modes_choose', args=[unicode(self.course.id)]) + response = self.client.get(choose_track_url) + + # Assert learner is enrolled in Audit track and sent to the dashboard + mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertEquals(mode, audit_mode) + self.assertTrue(is_active) + + redirect_url = reverse('dashboard') + self.assertRedirects(response, redirect_url) + + @httpretty.activate def test_choose_mode_audit_enroll_on_post(self): + self.mock_enterprise_learner_api() + self.mock_enterprise_course_enrollment_get_api() audit_mode = 'audit' # Create the course modes for mode in (audit_mode, 'verified'): @@ -394,7 +474,10 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest self.assertEqual(mode, audit_mode) self.assertTrue(is_active) + @httpretty.activate def test_remember_donation_for_course(self): + self.mock_enterprise_learner_api() + self.mock_enterprise_course_enrollment_get_api() # Create the course modes CourseModeFactory.create(mode_slug='honor', course_id=self.course.id) CourseModeFactory.create(mode_slug='verified', course_id=self.course.id, min_price=1) @@ -411,7 +494,10 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest expected_amount = decimal.Decimal(self.POST_PARAMS_FOR_COURSE_MODE['verified']['contribution']) self.assertEqual(actual_amount, expected_amount) + @httpretty.activate def test_successful_default_enrollment(self): + self.mock_enterprise_learner_api() + self.mock_enterprise_course_enrollment_get_api() # Create the course modes for mode in (CourseMode.DEFAULT_MODE_SLUG, 'verified'): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) @@ -433,7 +519,10 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest self.assertEqual(mode, CourseMode.DEFAULT_MODE_SLUG) self.assertEqual(is_active, True) + @httpretty.activate def test_unsupported_enrollment_mode_failure(self): + self.mock_enterprise_learner_api() + self.mock_enterprise_course_enrollment_get_api() # Create the supported course modes for mode in ('honor', 'verified'): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index f034b060fc6..11043417438 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -25,6 +25,7 @@ from edxmako.shortcuts import render_to_response from lms.djangoapps.commerce.utils import EcommerceService from openedx.core.djangoapps.embargo import api as embargo_api from openedx.features.enterprise_support import api as enterprise_api +from openedx.features.enterprise_support.api import get_enterprise_consent_url from student.models import CourseEnrollment from third_party_auth.decorators import tpa_hint_ends_existing_session from util import organizations_helpers as organization_api @@ -107,6 +108,16 @@ class ChooseModeView(View): # If there isn't a verified mode available, then there's nothing # to do on this page. Send the user to the dashboard. if not CourseMode.has_verified_mode(modes): + # If the learner has arrived at this screen via the traditional enrollment workflow, + # then they should already be enrolled in an audit mode for the course, assuming one has + # been configured. However, alternative enrollment workflows have been introduced into the + # system, such as third-party discovery. These workflows result in learners arriving + # directly at this screen, and they will not necessarily be pre-enrolled in the audit mode. + # In this particular case, Audit is the ONLY option available, and thus we need to ensure + # that the learner is truly enrolled before we redirect them away to the dashboard. + if len(modes) == 1 and modes.get(CourseMode.AUDIT): + CourseEnrollment.enroll(request.user, course_key, CourseMode.AUDIT) + return redirect(self._get_redirect_url_for_audit_enrollment(request, course_id)) return redirect(reverse('dashboard')) # If a user has already paid, redirect them to the dashboard. @@ -241,19 +252,14 @@ class ChooseModeView(View): allowed_modes = CourseMode.modes_for_course_dict(course_key) if requested_mode not in allowed_modes: return HttpResponseBadRequest(_("Enrollment mode not supported")) - - if requested_mode == 'audit': + if requested_mode in CourseMode.AUDIT_MODES: # If the learner has arrived at this screen via the traditional enrollment workflow, # then they should already be enrolled in an audit mode for the course, assuming one has # been configured. However, alternative enrollment workflows have been introduced into the # system, such as third-party discovery. These workflows result in learners arriving # directly at this screen, and they will not necessarily be pre-enrolled in the audit mode. - CourseEnrollment.enroll(request.user, course_key, CourseMode.AUDIT) - return redirect(reverse('dashboard')) - - if requested_mode == 'honor': CourseEnrollment.enroll(user, course_key, mode=requested_mode) - return redirect(reverse('dashboard')) + return redirect(self._get_redirect_url_for_audit_enrollment(request, course_id)) mode_info = allowed_modes[requested_mode] @@ -284,6 +290,44 @@ class ChooseModeView(View): ) ) + def _get_redirect_url_for_audit_enrollment(self, request, course_id): + """ + After a user has been enrolled in a course in an audit mode, determine the appropriate location + to which they ought to be redirected, bearing in mind enterprise data sharing consent considerations. + """ + enterprise_learner_data = enterprise_api.get_enterprise_learner_data(site=request.site, user=request.user) + + if enterprise_learner_data: + enterprise_learner = enterprise_learner_data[0] + # If we have an enterprise learner, check to see if the current course is in the enterprise's catalog. + is_course_in_enterprise_catalog = enterprise_api.is_course_in_enterprise_catalog( + site=request.site, + course_id=course_id, + enterprise_catalog_id=enterprise_learner['enterprise_customer']['catalog'] + ) + # If the course is in the catalog, check for an existing Enterprise enrollment + if is_course_in_enterprise_catalog: + client = enterprise_api.EnterpriseApiClient() + if not client.get_enterprise_course_enrollment(enterprise_learner['id'], course_id): + # If there's no existing Enterprise enrollment, create one. + client.post_enterprise_course_enrollment(request.user.username, course_id, None) + # Check if consent is required, and generate a redirect URL to the + # consent service if so; this function returns None if consent + # is not required or has already been granted. + consent_url = get_enterprise_consent_url( + request, + course_id, + user=request.user, + return_to='dashboard', + course_specific_return=False, + ) + # If we got a redirect URL for consent, go there. + if consent_url: + return consent_url + + # If the enrollment isn't Enterprise-linked, or if consent isn't necessary, go to the Dashboard. + return reverse('dashboard') + def _get_requested_mode(self, request_dict): """Get the user's requested mode diff --git a/openedx/features/enterprise_support/api.py b/openedx/features/enterprise_support/api.py index 592c1aa6f07..5f4c19170e6 100644 --- a/openedx/features/enterprise_support/api.py +++ b/openedx/features/enterprise_support/api.py @@ -59,6 +59,32 @@ class EnterpriseApiClient(object): jwt=jwt ) + def get_enterprise_course_enrollment(self, ec_user_id, course_id): + """ + Check for an EnterpriseCourseEnrollment linking a particular EnterpriseCustomerUser to a particular course. + """ + params = { + 'enterprise_customer_user': ec_user_id, + 'course_id': course_id, + } + try: + response = getattr(self.client, 'enterprise-course-enrollment').get(**params) + except (HttpClientError, HttpServerError): + message = ( + "An error occured while getting EnterpriseCourseEnrollment for EnterpriseCustomerUser with " + "ID {ec_user_id} and course run {course_id}." + ).format( + username=username, + course_id=course_id, + ) + LOGGER.exception(message) + raise EnterpriseApiException(message) + else: + if response.get('results'): + return response['results'][0] + else: + return None + def post_enterprise_course_enrollment(self, username, course_id, consent_granted): """ Create an EnterpriseCourseEnrollment by using the corresponding serializer (for validation). @@ -268,7 +294,7 @@ def consent_needed_for_course(user, course_id): return consent_necessary_for_course(user, course_id) -def get_enterprise_consent_url(request, course_id, user=None, return_to=None): +def get_enterprise_consent_url(request, course_id, user=None, return_to=None, course_specific_return=True): """ Build a URL to redirect the user to the Enterprise app to provide data sharing consent for a specific course ID. @@ -286,10 +312,15 @@ def get_enterprise_consent_url(request, course_id, user=None, return_to=None): if not consent_needed_for_course(user, course_id): return None + if course_specific_return: + reverse_args = (course_id,) + else: + reverse_args = tuple() + if return_to is None: return_path = request.path else: - return_path = reverse(return_to, args=(course_id,)) + return_path = reverse(return_to, args=reverse_args) url_params = { 'course_id': course_id, diff --git a/openedx/features/enterprise_support/tests/mixins/enterprise.py b/openedx/features/enterprise_support/tests/mixins/enterprise.py index 5415d73e28b..f4ce420e1e9 100644 --- a/openedx/features/enterprise_support/tests/mixins/enterprise.py +++ b/openedx/features/enterprise_support/tests/mixins/enterprise.py @@ -57,6 +57,18 @@ class EnterpriseServiceMockMixin(object): status=500 ) + def mock_enterprise_course_enrollment_get_api(self, **kwargs): + result = { + 'results': [kwargs] if kwargs else [] + } + httpretty.register_uri( + method=httpretty.GET, + uri=self.get_enterprise_url('enterprise-course-enrollment'), + body=json.dumps(result), + content_type='application/json', + status=200 + ) + def mock_enterprise_learner_api( self, catalog_id=1, diff --git a/openedx/features/enterprise_support/tests/test_api.py b/openedx/features/enterprise_support/tests/test_api.py index 0a796f8513d..b40d23b552e 100644 --- a/openedx/features/enterprise_support/tests/test_api.py +++ b/openedx/features/enterprise_support/tests/test_api.py @@ -194,6 +194,29 @@ class TestEnterpriseApi(unittest.TestCase): actual_url = get_enterprise_consent_url(request_mock, course_id, return_to=return_to) self.assertEqual(actual_url, expected_url) + @mock.patch('openedx.features.enterprise_support.api.consent_needed_for_course') + def test_get_enterprise_consent_url_next_provided_not_course_specific(self, needed_for_course_mock): + """ + Verify that get_enterprise_consent_url correctly builds URLs. + """ + needed_for_course_mock.return_value = True + + request_mock = mock.MagicMock( + user=None, + build_absolute_uri=lambda x: 'http://localhost:8000' + x # Don't do it like this in prod. Ever. + ) + + course_id = 'course-v1:edX+DemoX+Demo_Course' + + expected_url = ( + '/enterprise/grant_data_sharing_permissions?course_id=course-v1%3AedX%2BDemoX%2BDemo_' + 'Course&failure_url=http%3A%2F%2Flocalhost%3A8000%2Fdashboard%3Fconsent_failed%3Dcou' + 'rse-v1%253AedX%252BDemoX%252BDemo_Course&next=http%3A%2F%2Flocalhost%3A8000%2Fdashboard' + ) + + actual_url = get_enterprise_consent_url(request_mock, course_id, return_to='dashboard', course_specific_return=False) + self.assertEqual(actual_url, expected_url) + def test_get_dashboard_consent_notification_no_param(self): """ Test that the output of the consent notification renderer meets expectations. -- GitLab