From fc814aee95560877f4430177b4540fb0610a1b90 Mon Sep 17 00:00:00 2001 From: Jesse Shapiro Date: Mon, 12 Dec 2016 09:28:35 -0500 Subject: [PATCH] Add data sharing consent features for EnterpriseCustomer --- common/djangoapps/student/forms.py | 13 ++ common/djangoapps/student/views.py | 8 + .../djangoapps/third_party_auth/middleware.py | 31 ++++ .../djangoapps/third_party_auth/pipeline.py | 33 ++++ .../djangoapps/third_party_auth/settings.py | 10 +- .../third_party_auth/tests/specs/base.py | 13 +- .../tests/specs/test_testshib.py | 8 +- .../third_party_auth/tests/test_middleware.py | 51 ++++++ .../tests/test_pipeline_integration.py | 93 +++++++++++ .../third_party_auth/tests/test_settings.py | 7 + common/djangoapps/util/enterprise_helpers.py | 122 +++++++++++++++ .../util/tests/test_enterprise_helpers.py | 146 ++++++++++++++++++ lms/envs/common.py | 1 - lms/static/sass/views/_login-register.scss | 1 + lms/urls.py | 7 + .../djangoapps/user_api/tests/test_views.py | 37 +++++ openedx/core/djangoapps/user_api/views.py | 4 + requirements/edx/base.txt | 2 +- 18 files changed, 574 insertions(+), 13 deletions(-) create mode 100644 common/djangoapps/third_party_auth/tests/test_middleware.py create mode 100644 common/djangoapps/util/enterprise_helpers.py create mode 100644 common/djangoapps/util/tests/test_enterprise_helpers.py diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index ff1511bd9a..2b0acbe783 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -188,6 +188,19 @@ class AccountCreationForm(forms.Form): "required": _("To enroll, you must follow the honor code.") } ) + elif field_name == 'data_sharing_consent': + if field_value == "required": + self.fields[field_name] = TrueField( + error_messages={ + "required": _( + "You must consent to data sharing to register." + ) + } + ) + elif field_value == 'optional': + self.fields[field_name] = forms.BooleanField( + required=False + ) else: required = field_value == "required" min_length = 1 if field_name in ("gender", "level_of_education") else 2 diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 8bffc4112b..3e231367e1 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -46,6 +46,7 @@ from social.exceptions import AuthException, AuthAlreadyAssociated from edxmako.shortcuts import render_to_response, render_to_string +from util.enterprise_helpers import data_sharing_consent_requirement_at_login from course_modes.models import CourseMode from shoppingcart.api import order_history from student.models import ( @@ -1644,6 +1645,10 @@ def create_account_with_params(request, params): if should_link_with_social_auth or (third_party_auth.is_enabled() and pipeline.running(request)): params["password"] = pipeline.make_random_password() + # Add a form requirement for data sharing consent if the EnterpriseCustomer + # for the request requires it at login + extra_fields['data_sharing_consent'] = data_sharing_consent_requirement_at_login(request) + # if doing signup for an external authorization, then get email, password, name from the eamap # don't use the ones from the form, since the user could have hacked those # unless originally we didn't get a valid email or name from the external auth @@ -1740,6 +1745,9 @@ def create_account_with_params(request, params): if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + # Store received data sharing consent field values in the pipeline for use + # by any downstream pipeline elements which require them. + running_pipeline['kwargs']['data_sharing_consent'] = form.cleaned_data.get('data_sharing_consent', None) # Track the user's registration if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: diff --git a/common/djangoapps/third_party_auth/middleware.py b/common/djangoapps/third_party_auth/middleware.py index fe843f6a7b..f48fb6da09 100644 --- a/common/djangoapps/third_party_auth/middleware.py +++ b/common/djangoapps/third_party_auth/middleware.py @@ -23,3 +23,34 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware): redirect_uri = pipeline.AUTH_DISPATCH_URLS[auth_entry] return redirect_uri + + +class PipelineQuarantineMiddleware(object): + """ + Middleware flushes the session if a user agent with a quarantined session + attempts to leave the quarantined set of views. + """ + + def process_view(self, request, view_func, view_args, view_kwargs): # pylint: disable=unused-argument + """ + Check the session to see if we've quarantined the user to a particular + step of the authentication pipeline; if so, look up which modules the + user is allowed to browse to without breaking the pipeline. If the view + that's been requested is outside those modules, then flush the session. + + In general, this middleware should be used in cases where allowing the + user to exit the running pipeline would be undesirable, and where it'd + be better to flush the session state rather than allow it. Pipeline + quarantining is utilized by the Enterprise application to enforce + collection of user consent for sharing data with a linked third-party + authentication provider. + """ + running_pipeline = request.session.get('partial_pipeline') + + if not running_pipeline: + return + + view_module = view_func.__module__ + quarantined_modules = request.session.get('third_party_auth_quarantined_modules', None) + if quarantined_modules is not None and not any(view_module.startswith(mod) for mod in quarantined_modules): + request.session.flush() diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 2e2aa3c2f0..afc58478b9 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -74,6 +74,7 @@ from django.core.urlresolvers import reverse from django.http import HttpResponseBadRequest from django.shortcuts import redirect from social.apps.django_app.default import models +from social.apps.django_app.default.models import UserSocialAuth from social.exceptions import AuthException from social.pipeline import partial from social.pipeline.social_auth import associate_by_email @@ -200,6 +201,38 @@ def get(request): return request.session.get('partial_pipeline') +def get_real_social_auth_object(request): + """ + At times, the pipeline will have a "social" kwarg that contains a dictionary + rather than an actual DB-backed UserSocialAuth object. We need the real thing, + so this method allows us to get that by passing in the relevant request. + """ + running_pipeline = get(request) + if running_pipeline and 'social' in running_pipeline['kwargs']: + social = running_pipeline['kwargs']['social'] + if isinstance(social, dict): + social = UserSocialAuth.objects.get(uid=social.get('uid', '')) + return social + + +def quarantine_session(request, locations): + """ + Set a session variable indicating that the session is restricted + to being used in views contained in the modules listed by string + in the `locations` argument. + + Example: ``quarantine_session(request, ('enterprise.views',))`` + """ + request.session['third_party_auth_quarantined_modules'] = locations + + +def lift_quarantine(request): + """ + Remove the session quarantine variable. + """ + request.session.pop('third_party_auth_quarantined_modules', None) + + def get_authenticated_user(auth_provider, username, uid): """Gets a saved user authenticated by a particular backend. diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index a9864e203a..9a4c3ab1dc 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -10,9 +10,12 @@ If true, it: b) calls apply_settings(), passing in the Django settings """ +from util.enterprise_helpers import insert_enterprise_pipeline_elements + _FIELDS_STORED_IN_SESSION = ['auth_entry', 'next'] _MIDDLEWARE_CLASSES = ( 'third_party_auth.middleware.ExceptionMiddleware', + 'third_party_auth.middleware.PipelineQuarantineMiddleware', ) _SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard' @@ -37,7 +40,7 @@ def apply_settings(django_settings): # Inject our customized auth pipeline. All auth backends must work with # this pipeline. - django_settings.SOCIAL_AUTH_PIPELINE = ( + django_settings.SOCIAL_AUTH_PIPELINE = [ 'third_party_auth.pipeline.parse_query_params', 'social.pipeline.social_auth.social_details', 'social.pipeline.social_auth.social_uid', @@ -53,7 +56,10 @@ def apply_settings(django_settings): 'social.pipeline.user.user_details', 'third_party_auth.pipeline.set_logged_in_cookies', 'third_party_auth.pipeline.login_analytics', - ) + ] + + # Add enterprise pipeline elements if the enterprise app is installed + insert_enterprise_pipeline_elements(django_settings.SOCIAL_AUTH_PIPELINE) # Required so that we can use unmodified PSA OAuth2 backends: django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy' diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 48a29ecd56..c6eab3d0af 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -76,15 +76,16 @@ class IntegrationTestMixin(object): self.assertEqual(form_fields['email']['defaultValue'], self.USER_EMAIL) self.assertEqual(form_fields['name']['defaultValue'], self.USER_NAME) self.assertEqual(form_fields['username']['defaultValue'], self.USER_USERNAME) + registration_values = { + 'email': 'email-edited@tpa-test.none', + 'name': 'My Customized Name', + 'username': 'new_username', + 'honor_code': True, + } # Now complete the form: ajax_register_response = self.client.post( reverse('user_api_registration'), - { - 'email': 'email-edited@tpa-test.none', - 'name': 'My Customized Name', - 'username': 'new_username', - 'honor_code': True, - } + registration_values ) self.assertEqual(ajax_register_response.status_code, 200) # Then the AJAX will finish the third party auth: diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index 91664759f6..e8e874dea7 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -163,6 +163,7 @@ class TestShibIntegrationTest(IntegrationTestMixin, testutil.SAMLTestCase): def _configure_testshib_provider(self, **kwargs): """ Enable and configure the TestShib SAML IdP as a third_party_auth provider """ fetch_metadata = kwargs.pop('fetch_metadata', True) + assert_metadata_updates = kwargs.pop('assert_metadata_updates', True) kwargs.setdefault('name', self.PROVIDER_NAME) kwargs.setdefault('enabled', True) kwargs.setdefault('visible', True) @@ -176,9 +177,10 @@ class TestShibIntegrationTest(IntegrationTestMixin, testutil.SAMLTestCase): if fetch_metadata: self.assertTrue(httpretty.is_enabled()) num_changed, num_failed, num_total = fetch_saml_metadata() - self.assertEqual(num_failed, 0) - self.assertEqual(num_changed, 1) - self.assertEqual(num_total, 1) + if assert_metadata_updates: + self.assertEqual(num_failed, 0) + self.assertEqual(num_changed, 1) + self.assertEqual(num_total, 1) def do_provider_login(self, provider_redirect_url): """ Mocked: the user logs in to TestShib and then gets redirected back """ diff --git a/common/djangoapps/third_party_auth/tests/test_middleware.py b/common/djangoapps/third_party_auth/tests/test_middleware.py new file mode 100644 index 0000000000..740dbcc100 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_middleware.py @@ -0,0 +1,51 @@ +""" +Test the session-flushing middleware +""" +import unittest + +from django.conf import settings +from django.test import Client + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestSessionFlushMiddleware(unittest.TestCase): + """ + Ensure that if the pipeline is exited when it's been quarantined, + the entire session is flushed. + """ + def test_session_flush(self): + """ + Test that a quarantined session is flushed when navigating elsewhere + """ + client = Client() + session = client.session + session['fancy_variable'] = 13025 + session['partial_pipeline'] = 'pipeline_running' + session['third_party_auth_quarantined_modules'] = ('fake_quarantined_module',) + session.save() + client.get('/') + self.assertEqual(client.session.get('fancy_variable', None), None) + + def test_session_no_running_pipeline(self): + """ + Test that a quarantined session without a running pipeline is not flushed + """ + client = Client() + session = client.session + session['fancy_variable'] = 13025 + session['third_party_auth_quarantined_modules'] = ('fake_quarantined_module',) + session.save() + client.get('/') + self.assertEqual(client.session.get('fancy_variable', None), 13025) + + def test_session_no_quarantine(self): + """ + Test that a session with a running pipeline but no quarantine is not flushed + """ + client = Client() + session = client.session + session['fancy_variable'] = 13025 + session['partial_pipeline'] = 'pipeline_running' + session.save() + client.get('/') + self.assertEqual(client.session.get('fancy_variable', None), 13025) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index a43071cb7a..c15a8a9f31 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -5,6 +5,7 @@ import unittest from django.conf import settings from django import test from django.contrib.auth import models +import mock from third_party_auth import pipeline, provider from third_party_auth.tests import testutil @@ -208,3 +209,95 @@ class UrlFormationTestCase(TestCase): with self.assertRaises(ValueError): pipeline.get_complete_url(provider_id) + + +@unittest.skipUnless( + testutil.AUTH_FEATURES_KEY in settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES') +class TestPipelineUtilityFunctions(TestCase, test.TestCase): + """ + Test some of the isolated utility functions in the pipeline + """ + def setUp(self): + super(TestPipelineUtilityFunctions, self).setUp() + self.user = social_models.DjangoStorage.user.create_user(username='username', password='password') + self.social_auth = social_models.UserSocialAuth.objects.create( + user=self.user, + uid='fake uid', + provider='fake provider' + ) + + def test_get_real_social_auth_from_dict(self): + """ + Test that we can use a dictionary with a UID entry to retrieve a + database-backed UserSocialAuth object. + """ + request = mock.MagicMock( + session={ + 'partial_pipeline': { + 'kwargs': { + 'social': { + 'uid': 'fake uid' + } + } + } + } + ) + real_social = pipeline.get_real_social_auth_object(request) + self.assertEqual(real_social, self.social_auth) + + def test_get_real_social_auth(self): + """ + Test that trying to get a database-backed UserSocialAuth from an existing + instance returns correctly. + """ + request = mock.MagicMock( + session={ + 'partial_pipeline': { + 'kwargs': { + 'social': self.social_auth + } + } + } + ) + real_social = pipeline.get_real_social_auth_object(request) + self.assertEqual(real_social, self.social_auth) + + def test_get_real_social_auth_no_pipeline(self): + """ + Test that if there's no running pipeline, we return None when looking + for a database-backed UserSocialAuth object. + """ + request = mock.MagicMock(session={}) + real_social = pipeline.get_real_social_auth_object(request) + self.assertEqual(real_social, None) + + def test_get_real_social_auth_no_social(self): + """ + Test that if a UserSocialAuth object hasn't been attached to the pipeline as + `social`, we return none + """ + request = mock.MagicMock( + session={ + 'running_pipeline': { + 'kwargs': {} + } + } + ) + real_social = pipeline.get_real_social_auth_object(request) + self.assertEqual(real_social, None) + + def test_quarantine(self): + """ + Test that quarantining a session adds the correct flags, and that + lifting the quarantine similarly removes those flags. + """ + request = mock.MagicMock( + session={} + ) + pipeline.quarantine_session(request, locations=('my_totally_real_module', 'other_real_module',)) + self.assertEqual( + request.session['third_party_auth_quarantined_modules'], + ('my_totally_real_module', 'other_real_module',), + ) + pipeline.lift_quarantine(request) + self.assertNotIn('third_party_auth_quarantined_modules', request.session) diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py index 9b5943c62d..080ffc2c21 100644 --- a/common/djangoapps/third_party_auth/tests/test_settings.py +++ b/common/djangoapps/third_party_auth/tests/test_settings.py @@ -2,6 +2,7 @@ from third_party_auth import provider, settings from third_party_auth.tests import testutil +from util.enterprise_helpers import enterprise_enabled import unittest @@ -55,3 +56,9 @@ class SettingsUnitTest(testutil.TestCase): # bad in prod. settings.apply_settings(self.settings) self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS) + + @unittest.skipUnless(enterprise_enabled(), 'enterprise not enabled') + def test_enterprise_elements_inserted(self): + settings.apply_settings(self.settings) + self.assertIn('enterprise.tpa_pipeline.set_data_sharing_consent_record', self.settings.SOCIAL_AUTH_PIPELINE) + self.assertIn('enterprise.tpa_pipeline.verify_data_sharing_consent', self.settings.SOCIAL_AUTH_PIPELINE) diff --git a/common/djangoapps/util/enterprise_helpers.py b/common/djangoapps/util/enterprise_helpers.py new file mode 100644 index 0000000000..627c5cffb1 --- /dev/null +++ b/common/djangoapps/util/enterprise_helpers.py @@ -0,0 +1,122 @@ +""" +Helpers to access the enterprise app +""" +from django.conf import settings +from django.utils.translation import ugettext as _ + +try: + from enterprise.models import EnterpriseCustomer + from enterprise.tpa_pipeline import ( + active_provider_requests_data_sharing, + active_provider_enforces_data_sharing, + get_enterprise_customer_for_request, + ) + +except ImportError: + pass + +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + + +def enterprise_enabled(): + """ + Determines whether the Enterprise app is installed + """ + return 'enterprise' in settings.INSTALLED_APPS + + +def data_sharing_consent_requested(request): + """ + Determine if the EnterpriseCustomer for a given HTTP request + requests data sharing consent + """ + if not enterprise_enabled(): + return False + return active_provider_requests_data_sharing(request) + + +def data_sharing_consent_required_at_login(request): + """ + Determines if data sharing consent is required at + a given location + """ + if not enterprise_enabled(): + return False + return active_provider_enforces_data_sharing(request, EnterpriseCustomer.AT_LOGIN) + + +def data_sharing_consent_requirement_at_login(request): + """ + Returns either 'optional' or 'required' based on where we are. + """ + if not enterprise_enabled(): + return None + if data_sharing_consent_required_at_login(request): + return 'required' + if data_sharing_consent_requested(request): + return 'optional' + return None + + +def insert_enterprise_fields(request, form_desc): + """ + Enterprise methods which modify the logistration form are called from this method. + """ + if not enterprise_enabled(): + return + add_data_sharing_consent_field(request, form_desc) + + +def add_data_sharing_consent_field(request, form_desc): + """ + Adds a checkbox field to be selected if the user consents to share data with + the EnterpriseCustomer attached to the SSO provider with which they're authenticating. + """ + enterprise_customer = get_enterprise_customer_for_request(request) + required = data_sharing_consent_required_at_login(request) + + if not data_sharing_consent_requested(request): + return + + label = _( + "I agree to allow {platform_name} to share data about my enrollment, " + "completion and performance in all {platform_name} courses and programs " + "where my enrollment is sponsored by {ec_name}." + ).format( + platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME), + ec_name=enterprise_customer.name + ) + + error_msg = _( + "To link your account with {ec_name}, you are required to consent to data sharing." + ).format( + ec_name=enterprise_customer.name + ) + + form_desc.add_field( + "data_sharing_consent", + label=label, + field_type="checkbox", + default=False, + required=required, + error_messages={"required": error_msg}, + ) + + +def insert_enterprise_pipeline_elements(pipeline): + """ + If the enterprise app is enabled, insert additional elements into the + pipeline so that data sharing consent views are used. + """ + if not enterprise_enabled(): + return + + additional_elements = ( + 'enterprise.tpa_pipeline.set_data_sharing_consent_record', + 'enterprise.tpa_pipeline.verify_data_sharing_consent', + ) + # Find the item we need to insert the data sharing consent elements before + insert_point = pipeline.index('social.pipeline.social_auth.load_extra_data') + + for index, element in enumerate(additional_elements): + pipeline.insert(insert_point + index, element) diff --git a/common/djangoapps/util/tests/test_enterprise_helpers.py b/common/djangoapps/util/tests/test_enterprise_helpers.py new file mode 100644 index 0000000000..76ea516876 --- /dev/null +++ b/common/djangoapps/util/tests/test_enterprise_helpers.py @@ -0,0 +1,146 @@ +""" +Test the enterprise app helpers +""" +import unittest + +from django.conf import settings +import mock + +from util.enterprise_helpers import ( + enterprise_enabled, + data_sharing_consent_requested, + data_sharing_consent_required_at_login, + data_sharing_consent_requirement_at_login, + insert_enterprise_fields, + insert_enterprise_pipeline_elements +) + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestEnterpriseHelpers(unittest.TestCase): + """ + Test enterprise app helpers + """ + + @mock.patch('util.enterprise_helpers.enterprise_enabled') + def test_utils_with_enterprise_disabled(self, mock_enterprise_enabled): + """ + Test that the enterprise app not being available causes + the utilities to return the expected default values. + """ + mock_enterprise_enabled.return_value = False + self.assertFalse(data_sharing_consent_requested(None)) + self.assertFalse(data_sharing_consent_required_at_login(None)) + self.assertEqual(data_sharing_consent_requirement_at_login(None), None) + self.assertEqual(insert_enterprise_fields(None, None), None) + self.assertEqual(insert_enterprise_pipeline_elements(None), None) + + def test_enterprise_enabled(self): + """ + The test settings inherit from common, which have the enterprise + app installed; therefore, it should appear installed here. + """ + self.assertTrue(enterprise_enabled()) + + @mock.patch('enterprise.tpa_pipeline.get_enterprise_customer_for_request') + def test_data_sharing_consent_requested(self, mock_get_ec): + """ + Test that we correctly check whether data sharing consent is requested. + """ + request = mock.MagicMock(session={'partial_pipeline': 'thing'}) + mock_get_ec.return_value = mock.MagicMock(requests_data_sharing_consent=True) + self.assertTrue(data_sharing_consent_requested(request)) + mock_get_ec.return_value = mock.MagicMock(requests_data_sharing_consent=False) + self.assertFalse(data_sharing_consent_requested(request)) + mock_get_ec.return_value = None + self.assertFalse(data_sharing_consent_requested(request)) + request = mock.MagicMock(session={}) + self.assertFalse(data_sharing_consent_requested(request)) + + @mock.patch('enterprise.tpa_pipeline.get_enterprise_customer_for_request') + def test_data_sharing_consent_required(self, mock_get_ec): + """ + Test that we correctly check whether data sharing consent is required at login. + """ + check_method = mock.MagicMock(return_value=True) + request = mock.MagicMock(session={'partial_pipeline': 'thing'}) + mock_get_ec.return_value = mock.MagicMock(enforces_data_sharing_consent=check_method) + self.assertTrue(data_sharing_consent_required_at_login(request)) + check_method.return_value = False + mock_get_ec.return_value = mock.MagicMock(enforces_data_sharing_consent=check_method) + self.assertFalse(data_sharing_consent_required_at_login(request)) + mock_get_ec.return_value = None + self.assertFalse(data_sharing_consent_required_at_login(request)) + request = mock.MagicMock(session={}) + self.assertFalse(data_sharing_consent_required_at_login(request)) + + @mock.patch('enterprise.tpa_pipeline.get_enterprise_customer_for_request') + def test_data_sharing_consent_requirement(self, mock_get_ec): + """ + Test that we get the correct requirement string for the current consent statae. + """ + request = mock.MagicMock(session={'partial_pipeline': 'thing'}) + mock_ec = mock.MagicMock( + enforces_data_sharing_consent=mock.MagicMock(return_value=True), + requests_data_sharing_consent=True, + ) + mock_get_ec.return_value = mock_ec + self.assertEqual(data_sharing_consent_requirement_at_login(request), 'required') + mock_ec.enforces_data_sharing_consent.return_value = False + self.assertEqual(data_sharing_consent_requirement_at_login(request), 'optional') + mock_ec.requests_data_sharing_consent = False + self.assertEqual(data_sharing_consent_requirement_at_login(request), None) + + @mock.patch('util.enterprise_helpers.get_enterprise_customer_for_request') + @mock.patch('enterprise.tpa_pipeline.get_enterprise_customer_for_request') + @mock.patch('util.enterprise_helpers.configuration_helpers') + def test_insert_enterprise_fields(self, mock_config_helpers, mock_get_ec, mock_get_ec2): + """ + Test that the insertion of the enterprise fields is processed as expected. + """ + request = mock.MagicMock(session={'partial_pipeline': 'thing'}) + mock_ec = mock.MagicMock( + enforces_data_sharing_consent=mock.MagicMock(return_value=True), + requests_data_sharing_consent=True, + ) + # Name values in a MagicMock constructor don't fill a `name` attribute + mock_ec.name = 'MassiveCorp' + mock_get_ec.return_value = mock_ec + mock_get_ec2.return_value = mock_ec + mock_config_helpers.get_value.return_value = 'OpenEdX' + form_desc = mock.MagicMock() + form_desc.add_field.return_value = None + expected_label = ( + "I agree to allow OpenEdX to share data about my enrollment, " + "completion and performance in all OpenEdX courses and programs " + "where my enrollment is sponsored by MassiveCorp." + ) + expected_err_msg = ( + "To link your account with MassiveCorp, you are required to consent to data sharing." + ) + insert_enterprise_fields(request, form_desc) + mock_ec.enforces_data_sharing_consent.return_value = False + insert_enterprise_fields(request, form_desc) + calls = [ + mock.call( + 'data_sharing_consent', + label=expected_label, + field_type='checkbox', + default=False, + required=True, + error_messages={'required': expected_err_msg} + ), + mock.call( + 'data_sharing_consent', + label=expected_label, + field_type='checkbox', + default=False, + required=False, + error_messages={'required': expected_err_msg} + ) + ] + form_desc.add_field.assert_has_calls(calls) + form_desc.add_field.reset_mock() + mock_ec.requests_data_sharing_consent = False + insert_enterprise_fields(request, form_desc) + form_desc.add_field.assert_not_called() diff --git a/lms/envs/common.py b/lms/envs/common.py index 51359baf23..f380bb2577 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2662,7 +2662,6 @@ OPTIONAL_APPS = ( # Enterprise App (http://github.com/edx/edx-enterprise) 'enterprise', - # Required by the Enterprise App 'django_object_actions', # https://github.com/crccheck/django-object-actions ) diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index ace30fe581..4ed38d1fba 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -245,6 +245,7 @@ color: $red; } + &[for="register-data_sharing_consent"], &[for="register-honor_code"], &[for="register-terms_of_service"] { display: inline-block; diff --git a/lms/urls.py b/lms/urls.py index daa61f8215..eac7ebe5ee 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -17,6 +17,7 @@ from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from django_comment_common.models import ForumsConfig from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from util.enterprise_helpers import enterprise_enabled # Uncomment the next two lines to enable the admin: if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): @@ -859,6 +860,12 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): url(r'^login_oauth_token/(?P[^/]+)/$', 'student.views.login_oauth_token'), ) +# Enterprise +if enterprise_enabled(): + urlpatterns += ( + url(r'', include('enterprise.urls')), + ) + # OAuth token exchange if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'): urlpatterns += ( diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 1731101a32..9b881de733 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1026,6 +1026,43 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): } ) + @mock.patch('util.enterprise_helpers.active_provider_requests_data_sharing') + @mock.patch('util.enterprise_helpers.active_provider_enforces_data_sharing') + @mock.patch('util.enterprise_helpers.get_enterprise_customer_for_request') + @mock.patch('util.enterprise_helpers.configuration_helpers') + def test_register_form_consent_field(self, config_helper, get_ec, mock_enforce, mock_request): + """ + Test that if we have an EnterpriseCustomer active for the request, and that + EnterpriseCustomer is set to require data sharing consent, the correct + field is added to the form descriptor. + """ + fake_ec = mock.MagicMock( + enforces_data_sharing_consent=mock.MagicMock(return_value=True), + requests_data_sharing_consent=True, + ) + fake_ec.name = 'MegaCorp' + get_ec.return_value = fake_ec + config_helper.get_value.return_value = 'OpenEdX' + mock_request.return_value = True + mock_enforce.return_value = True + self._assert_reg_field( + dict(), + { + u"name": u"data_sharing_consent", + u"type": u"checkbox", + u"required": True, + u"label": ( + "I agree to allow OpenEdX to share data about my enrollment, " + "completion and performance in all OpenEdX courses and programs " + "where my enrollment is sponsored by MegaCorp." + ), + u"defaultValue": False, + u"errorMessages": { + u'required': u'To link your account with MegaCorp, you are required to consent to data sharing.', + } + } + ) + @mock.patch('openedx.core.djangoapps.user_api.views._') def test_register_form_level_of_education_translations(self, fake_gettext): fake_gettext.side_effect = lambda text: text + ' TRANSLATED' diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 2d62520990..ba5a412fad 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -31,6 +31,7 @@ from student.cookies import set_logged_in_cookies from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser from util.json_request import JsonResponse +from util.enterprise_helpers import insert_enterprise_fields from .preferences.api import get_country_time_zones, update_email_opt_in from .helpers import FormDescription, shim_student_view, require_post_params from .models import UserPreference, UserProfile @@ -279,6 +280,9 @@ class RegistrationView(APIView): required=self._is_field_required(field_name) ) + # Add any Enterprise fields if the app is enabled + insert_enterprise_fields(request, form_desc) + return HttpResponse(form_desc.to_json(), content_type="application/json") @method_decorator(csrf_exempt) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 368fc52a1e..e753b0c41c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -46,7 +46,7 @@ edx-drf-extensions==1.2.1 edx-lint==0.4.3 edx-django-oauth2-provider==1.1.4 edx-django-sites-extensions==2.1.1 -edx-enterprise==0.1.0 +edx-enterprise==0.6.0 edx-oauth2-provider==1.2.0 edx-opaque-keys==0.4.0 edx-organizations==0.4.1