Commit 25383a60 by Jillian Vogel

Ensure Enterprise-integrated courses require user consent before granting access…

Ensure Enterprise-integrated courses require user consent before granting access to Wiki and Discussion forum.

* User-facing links are gated; internal services are not.
* Adds view decorator data_sharing_consent_required
* Renames `get_course_specific_consent_url` to `get_enterprise_consent_url`,
  which now checks `consent_needed_for_course` before returning a consent URL.
parent 27dc5846
...@@ -3,9 +3,11 @@ Helpers to access the enterprise app ...@@ -3,9 +3,11 @@ Helpers to access the enterprise app
""" """
import logging import logging
from functools import wraps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.shortcuts import redirect
from django.utils.http import urlencode from django.utils.http import urlencode
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
try: try:
...@@ -70,6 +72,36 @@ class EnterpriseApiClient(object): ...@@ -70,6 +72,36 @@ class EnterpriseApiClient(object):
raise EnterpriseApiException(message) raise EnterpriseApiException(message)
def data_sharing_consent_required(view_func):
"""
Decorator which makes a view method redirect to the Data Sharing Consent form if:
* The wrapped method is passed request, course_id as the first two arguments.
* Enterprise integration is enabled
* Data sharing consent is required before accessing this course view.
* The request.user has not yet given data sharing consent for this course.
After granting consent, the user will be redirected back to the original request.path.
"""
@wraps(view_func)
def inner(request, course_id, *args, **kwargs):
"""
Redirect to the consent page if the request.user must consent to data sharing before viewing course_id.
Otherwise, just call the wrapped view function.
"""
# Redirect to the consent URL, if consent is required.
consent_url = get_enterprise_consent_url(request, course_id)
if consent_url:
return redirect(consent_url)
# Otherwise, drop through to wrapped view
return view_func(request, course_id, *args, **kwargs)
return inner
def enterprise_enabled(): def enterprise_enabled():
""" """
Determines whether the Enterprise app is installed Determines whether the Enterprise app is installed
...@@ -87,14 +119,32 @@ def consent_needed_for_course(user, course_id): ...@@ -87,14 +119,32 @@ def consent_needed_for_course(user, course_id):
return consent_necessary_for_course(user, course_id) return consent_necessary_for_course(user, course_id)
def get_course_specific_consent_url(request, course_id, return_to): def get_enterprise_consent_url(request, course_id, user=None, return_to=None):
""" """
Build a URL to redirect the user to the Enterprise app to provide data sharing Build a URL to redirect the user to the Enterprise app to provide data sharing
consent for a specific course ID. consent for a specific course ID.
Arguments:
* request: Request object
* course_id: Course key/identifier string.
* user: user to check for consent. If None, uses request.user
* return_to: url name label for the page to return to after consent is granted.
If None, return to request.path instead.
""" """
if user is None:
user = request.user
if not consent_needed_for_course(user, course_id):
return None
if return_to is None:
return_path = request.path
else:
return_path = reverse(return_to, args=(course_id,))
url_params = { url_params = {
'course_id': course_id, 'course_id': course_id,
'next': request.build_absolute_uri(reverse(return_to, args=(course_id,))) 'next': request.build_absolute_uri(return_path)
} }
querystring = urlencode(url_params) querystring = urlencode(url_params)
full_url = reverse('grant_data_sharing_permissions') + '?' + querystring full_url = reverse('grant_data_sharing_permissions') + '?' + querystring
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
Mixins for the EnterpriseApiClient. Mixins for the EnterpriseApiClient.
""" """
import json import json
import mock
import httpretty import httpretty
from django.conf import settings from django.conf import settings
...@@ -55,3 +56,37 @@ class EnterpriseServiceMockMixin(object): ...@@ -55,3 +56,37 @@ class EnterpriseServiceMockMixin(object):
content_type='application/json', content_type='application/json',
status=500 status=500
) )
class EnterpriseTestConsentRequired(object):
"""
Mixin to help test the data_sharing_consent_required decorator.
"""
def verify_consent_required(self, client, url, status_code=200):
"""
Verify that the given URL redirects to the consent page when consent is required,
and doesn't redirect to the consent page when consent is not required.
Arguments:
* self: ignored
* client: the TestClient instance to be used
* url: URL to test
* status_code: expected status code of URL when no data sharing consent is required.
"""
with mock.patch('util.enterprise_helpers.enterprise_enabled', return_value=True):
with mock.patch('util.enterprise_helpers.consent_necessary_for_course') as mock_consent_necessary:
# Ensure that when consent is necessary, the user is redirected to the consent page.
mock_consent_necessary.return_value = True
response = client.get(url)
assert response.status_code == 302
assert 'grant_data_sharing_permissions' in response.url # pylint: disable=no-member
# Ensure that when consent is not necessary, the user continues through to the requested page.
mock_consent_necessary.return_value = False
response = client.get(url)
assert response.status_code == status_code
# If we were expecting a redirect, ensure it's not to the data sharing permission page
if status_code == 302:
assert 'grant_data_sharing_permissions' not in response.url # pylint: disable=no-member
return response
...@@ -4,11 +4,14 @@ Test the enterprise app helpers ...@@ -4,11 +4,14 @@ Test the enterprise app helpers
import unittest import unittest
from django.conf import settings from django.conf import settings
from django.http import HttpResponseRedirect
from django.test.utils import override_settings
import mock import mock
from util.enterprise_helpers import ( from util.enterprise_helpers import (
enterprise_enabled, enterprise_enabled,
insert_enterprise_pipeline_elements, insert_enterprise_pipeline_elements,
data_sharing_consent_required,
set_enterprise_branding_filter_param, set_enterprise_branding_filter_param,
get_enterprise_branding_filter_param, get_enterprise_branding_filter_param,
get_enterprise_customer_logo_url get_enterprise_customer_logo_url
...@@ -21,21 +24,28 @@ class TestEnterpriseHelpers(unittest.TestCase): ...@@ -21,21 +24,28 @@ class TestEnterpriseHelpers(unittest.TestCase):
Test enterprise app helpers Test enterprise app helpers
""" """
@mock.patch('util.enterprise_helpers.enterprise_enabled') @override_settings(ENABLE_ENTERPRISE_INTEGRATION=False)
def test_utils_with_enterprise_disabled(self, mock_enterprise_enabled): def test_utils_with_enterprise_disabled(self):
""" """
Test that the enterprise app not being available causes Test that disabling the enterprise integration flag causes
the utilities to return the expected default values. the utilities to return the expected default values.
""" """
mock_enterprise_enabled.return_value = False self.assertFalse(enterprise_enabled())
self.assertEqual(insert_enterprise_pipeline_elements(None), None) self.assertEqual(insert_enterprise_pipeline_elements(None), None)
def test_enterprise_enabled(self): @override_settings(ENABLE_ENTERPRISE_INTEGRATION=True)
def test_utils_with_enterprise_enabled(self):
""" """
The test settings inherit from common, which have the enterprise Test that enabling enterprise integration (which is currently on by default) causes the
app installed; therefore, it should appear installed here. the utilities to return the expected values.
""" """
self.assertTrue(enterprise_enabled()) self.assertTrue(enterprise_enabled())
pipeline = ['abc', 'social.pipeline.social_auth.load_extra_data', 'def']
insert_enterprise_pipeline_elements(pipeline)
self.assertEqual(pipeline, ['abc',
'enterprise.tpa_pipeline.handle_enterprise_logistration',
'social.pipeline.social_auth.load_extra_data',
'def'])
def test_set_enterprise_branding_filter_param(self): def test_set_enterprise_branding_filter_param(self):
""" """
...@@ -51,7 +61,7 @@ class TestEnterpriseHelpers(unittest.TestCase): ...@@ -51,7 +61,7 @@ class TestEnterpriseHelpers(unittest.TestCase):
set_enterprise_branding_filter_param(request, provider_id=provider_id) set_enterprise_branding_filter_param(request, provider_id=provider_id)
self.assertEqual(get_enterprise_branding_filter_param(request), {'provider_id': provider_id}) self.assertEqual(get_enterprise_branding_filter_param(request), {'provider_id': provider_id})
@mock.patch('util.enterprise_helpers.enterprise_enabled', mock.Mock(return_value=True)) @override_settings(ENABLE_ENTERPRISE_INTEGRATION=True)
def test_get_enterprise_customer_logo_url(self): def test_get_enterprise_customer_logo_url(self):
""" """
Test test_get_enterprise_customer_logo_url return the logo url as desired. Test test_get_enterprise_customer_logo_url return the logo url as desired.
...@@ -75,7 +85,7 @@ class TestEnterpriseHelpers(unittest.TestCase): ...@@ -75,7 +85,7 @@ class TestEnterpriseHelpers(unittest.TestCase):
logo_url = get_enterprise_customer_logo_url(request) logo_url = get_enterprise_customer_logo_url(request)
self.assertEqual(logo_url, '/test/image.png') self.assertEqual(logo_url, '/test/image.png')
@mock.patch('util.enterprise_helpers.enterprise_enabled', mock.Mock(return_value=False)) @override_settings(ENABLE_ENTERPRISE_INTEGRATION=False)
def test_get_enterprise_customer_logo_url_return_none(self): def test_get_enterprise_customer_logo_url_return_none(self):
""" """
Test get_enterprise_customer_logo_url return 'None' when enterprise application is not installed. Test get_enterprise_customer_logo_url return 'None' when enterprise application is not installed.
...@@ -88,7 +98,7 @@ class TestEnterpriseHelpers(unittest.TestCase): ...@@ -88,7 +98,7 @@ class TestEnterpriseHelpers(unittest.TestCase):
logo_url = get_enterprise_customer_logo_url(request) logo_url = get_enterprise_customer_logo_url(request)
self.assertEqual(logo_url, None) self.assertEqual(logo_url, None)
@mock.patch('util.enterprise_helpers.enterprise_enabled', mock.Mock(return_value=True)) @override_settings(ENABLE_ENTERPRISE_INTEGRATION=True)
@mock.patch('util.enterprise_helpers.get_enterprise_branding_filter_param', mock.Mock(return_value=None)) @mock.patch('util.enterprise_helpers.get_enterprise_branding_filter_param', mock.Mock(return_value=None))
def test_get_enterprise_customer_logo_url_return_none_when_param_missing(self): def test_get_enterprise_customer_logo_url_return_none_when_param_missing(self):
""" """
...@@ -101,3 +111,84 @@ class TestEnterpriseHelpers(unittest.TestCase): ...@@ -101,3 +111,84 @@ class TestEnterpriseHelpers(unittest.TestCase):
with mock.patch('enterprise.utils.get_enterprise_branding_info_by_provider_id', return_value=branding_info): with mock.patch('enterprise.utils.get_enterprise_branding_info_by_provider_id', return_value=branding_info):
logo_url = get_enterprise_customer_logo_url(request) logo_url = get_enterprise_customer_logo_url(request)
self.assertEqual(logo_url, None) self.assertEqual(logo_url, None)
def check_data_sharing_consent(self, consent_required=False, consent_url=None):
"""
Used to test the data_sharing_consent_required view decorator.
"""
# Test by wrapping a function that has the expected signature
@data_sharing_consent_required
def view_func(request, course_id, *args, **kwargs):
"""
Return the function arguments, so they can be tested.
"""
return ((request, course_id,) + args, kwargs)
# Call the wrapped function
args = (mock.MagicMock(), 'course-id', 'another arg', 'and another')
kwargs = dict(a=1, b=2, c=3)
response = view_func(*args, **kwargs)
# If consent required, then the response should be a redirect to the consent URL, and the view function would
# not be called.
if consent_required:
self.assertIsInstance(response, HttpResponseRedirect)
self.assertEquals(response.url, consent_url) # pylint: disable=no-member
# Otherwise, the view function should have been called with the expected arguments.
else:
self.assertEqual(response, (args, kwargs))
@mock.patch('util.enterprise_helpers.enterprise_enabled')
@mock.patch('util.enterprise_helpers.consent_necessary_for_course')
def test_data_consent_required_enterprise_disabled(self,
mock_consent_necessary,
mock_enterprise_enabled):
"""
Verify that the wrapped view is called directly when enterprise integration is disabled,
without checking for course consent necessary.
"""
mock_enterprise_enabled.return_value = False
self.check_data_sharing_consent(consent_required=False)
mock_enterprise_enabled.assert_called_once()
mock_consent_necessary.assert_not_called()
@mock.patch('util.enterprise_helpers.enterprise_enabled')
@mock.patch('util.enterprise_helpers.consent_necessary_for_course')
def test_no_course_data_consent_required(self,
mock_consent_necessary,
mock_enterprise_enabled):
"""
Verify that the wrapped view is called directly when enterprise integration is enabled,
and no course consent is required.
"""
mock_enterprise_enabled.return_value = True
mock_consent_necessary.return_value = False
self.check_data_sharing_consent(consent_required=False)
mock_enterprise_enabled.assert_called_once()
mock_consent_necessary.assert_called_once()
@mock.patch('util.enterprise_helpers.enterprise_enabled')
@mock.patch('util.enterprise_helpers.consent_necessary_for_course')
@mock.patch('util.enterprise_helpers.get_enterprise_consent_url')
def test_data_consent_required(self, mock_get_consent_url, mock_consent_necessary, mock_enterprise_enabled):
"""
Verify that the wrapped function returns a redirect to the consent URL when enterprise integration is enabled,
and course consent is required.
"""
mock_enterprise_enabled.return_value = True
mock_consent_necessary.return_value = True
consent_url = '/abc/def'
mock_get_consent_url.return_value = consent_url
self.check_data_sharing_consent(consent_required=True, consent_url=consent_url)
mock_get_consent_url.assert_called_once()
mock_enterprise_enabled.assert_called_once()
mock_consent_necessary.assert_called_once()
...@@ -10,6 +10,7 @@ from courseware.courses import get_course_with_access, get_course_overview_with_ ...@@ -10,6 +10,7 @@ from courseware.courses import get_course_with_access, get_course_overview_with_
from courseware.access import has_access from courseware.access import has_access
from student.models import CourseEnrollment from student.models import CourseEnrollment
from util.request import course_id_from_url from util.request import course_id_from_url
from util.enterprise_helpers import get_enterprise_consent_url
class WikiAccessMiddleware(object): class WikiAccessMiddleware(object):
...@@ -75,6 +76,12 @@ class WikiAccessMiddleware(object): ...@@ -75,6 +76,12 @@ class WikiAccessMiddleware(object):
# if a user is logged in, but not authorized to see a page, # if a user is logged in, but not authorized to see a page,
# we'll redirect them to the course about page # we'll redirect them to the course about page
return redirect('about_course', course_id.to_deprecated_string()) return redirect('about_course', course_id.to_deprecated_string())
# If we need enterprise data sharing consent for this course, then redirect to the form.
consent_url = get_enterprise_consent_url(request, course_id)
if consent_url:
return redirect(consent_url)
# set the course onto here so that the wiki template can show the course navigation # set the course onto here so that the wiki template can show the course navigation
request.course = course request.course = course
else: else:
......
...@@ -2,6 +2,7 @@ from django.core.urlresolvers import reverse ...@@ -2,6 +2,7 @@ from django.core.urlresolvers import reverse
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from courseware.tests.tests import LoginEnrollmentTestCase from courseware.tests.tests import LoginEnrollmentTestCase
from util.tests.mixins.enterprise import EnterpriseTestConsentRequired
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -9,7 +10,7 @@ from mock import patch ...@@ -9,7 +10,7 @@ from mock import patch
@attr(shard=1) @attr(shard=1)
class WikiRedirectTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCase, ModuleStoreTestCase):
""" """
Tests for wiki course redirection. Tests for wiki course redirection.
""" """
...@@ -196,3 +197,25 @@ class WikiRedirectTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -196,3 +197,25 @@ class WikiRedirectTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
resp = self.client.get(course_wiki_page, follow=True, HTTP_REFERER=referer) resp = self.client.get(course_wiki_page, follow=True, HTTP_REFERER=referer)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
@patch.dict("django.conf.settings.FEATURES", {'ALLOW_WIKI_ROOT_ACCESS': True})
def test_consent_required(self):
"""
Test that enterprise data sharing consent is required when enabled for the various courseware views.
"""
# Public wikis can be accessed by non-enrolled users, and so direct access is not gated by the consent page
course = CourseFactory.create()
course.allow_public_wiki_access = False
course.save()
# However, for private wikis, enrolled users must pass through the consent gate
# (Unenrolled users are redirected to course/about)
course_id = unicode(course.id)
self.login(self.student, self.password)
self.enroll(course)
for (url, status_code) in (
(reverse('course_wiki', kwargs={'course_id': course_id}), 302),
('/courses/{}/wiki/'.format(course_id), 200),
):
self.verify_consent_required(self.client, url, status_code)
...@@ -16,6 +16,7 @@ from courseware.courses import get_course_by_id ...@@ -16,6 +16,7 @@ from courseware.courses import get_course_by_id
from course_wiki.utils import course_wiki_slug from course_wiki.utils import course_wiki_slug
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from util.enterprise_helpers import data_sharing_consent_required
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -29,7 +30,8 @@ def root_create(request): # pylint: disable=unused-argument ...@@ -29,7 +30,8 @@ def root_create(request): # pylint: disable=unused-argument
return redirect('wiki:get', path=root.path) return redirect('wiki:get', path=root.path)
def course_wiki_redirect(request, course_id): # pylint: disable=unused-argument @data_sharing_consent_required
def course_wiki_redirect(request, course_id, wiki_path=""): # pylint: disable=unused-argument
""" """
This redirects to whatever page on the wiki that the course designates This redirects to whatever page on the wiki that the course designates
as it's home page. A course's wiki must be an article on the root (for as it's home page. A course's wiki must be an article on the root (for
...@@ -50,7 +52,7 @@ def course_wiki_redirect(request, course_id): # pylint: disable=unused-argument ...@@ -50,7 +52,7 @@ def course_wiki_redirect(request, course_id): # pylint: disable=unused-argument
return redirect("wiki:get", path="") return redirect("wiki:get", path="")
try: try:
urlpath = URLPath.get_by_path(course_slug, select_related=True) urlpath = URLPath.get_by_path(wiki_path or course_slug, select_related=True)
results = list(Article.objects.filter(id=urlpath.article.id)) results = list(Article.objects.filter(id=urlpath.article.id))
if results: if results:
......
...@@ -60,9 +60,8 @@ class CourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): ...@@ -60,9 +60,8 @@ class CourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
resp = self.client.get(url) resp = self.client.get(url)
self.assertNotIn("You are not currently enrolled in this course", resp.content) self.assertNotIn("You are not currently enrolled in this course", resp.content)
@mock.patch('courseware.views.views.get_course_specific_consent_url') @mock.patch('courseware.views.views.get_enterprise_consent_url')
@mock.patch('courseware.views.views.consent_needed_for_course') def test_redirection_missing_enterprise_consent(self, mock_get_url):
def test_redirection_missing_enterprise_consent(self, mock_consent_needed, mock_get_url):
""" """
Verify that users viewing the course info who are enrolled, but have not provided Verify that users viewing the course info who are enrolled, but have not provided
data sharing consent, are first redirected to a consent page, and then, once they've data sharing consent, are first redirected to a consent page, and then, once they've
...@@ -70,7 +69,6 @@ class CourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): ...@@ -70,7 +69,6 @@ class CourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
""" """
self.setup_user() self.setup_user()
self.enroll(self.course) self.enroll(self.course)
mock_consent_needed.return_value = True
mock_get_url.return_value = reverse('dashboard') mock_get_url.return_value = reverse('dashboard')
url = reverse('info', args=[self.course.id.to_deprecated_string()]) url = reverse('info', args=[self.course.id.to_deprecated_string()])
...@@ -80,8 +78,8 @@ class CourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): ...@@ -80,8 +78,8 @@ class CourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
response, response,
reverse('dashboard') reverse('dashboard')
) )
mock_consent_needed.assert_called_once_with(self.user, unicode(self.course.id)) mock_get_url.assert_called_once()
mock_consent_needed.return_value = False mock_get_url.return_value = None
response = self.client.get(url) response = self.client.get(url)
self.assertNotIn("You are not currently enrolled in this course", response.content) self.assertNotIn("You are not currently enrolled in this course", response.content)
......
...@@ -197,28 +197,28 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -197,28 +197,28 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
) )
) )
@patch('courseware.views.index.get_course_specific_consent_url') @patch('courseware.views.index.get_enterprise_consent_url')
@patch('courseware.views.index.consent_needed_for_course') def test_redirection_missing_enterprise_consent(self, mock_get_url):
def test_redirection_missing_enterprise_consent(self, mock_consent_needed, mock_get_url):
""" """
Verify that enrolled students are redirected to the Enterprise consent Verify that enrolled students are redirected to the Enterprise consent
URL if a linked Enterprise Customer requires data sharing consent URL if a linked Enterprise Customer requires data sharing consent
and it has not yet been provided. and it has not yet been provided.
""" """
mock_consent_needed.return_value = True
mock_get_url.return_value = reverse('dashboard') mock_get_url.return_value = reverse('dashboard')
self.login(self.enrolled_user) self.login(self.enrolled_user)
response = self.client.get( url = reverse(
reverse( 'courseware',
'courseware', kwargs={'course_id': self.course.id.to_deprecated_string()}
kwargs={'course_id': self.course.id.to_deprecated_string()}
)
) )
response = self.client.get(url)
self.assertRedirects( self.assertRedirects(
response, response,
reverse('dashboard') reverse('dashboard')
) )
mock_consent_needed.assert_called_once_with(self.enrolled_user, unicode(self.course.id)) mock_get_url.assert_called_once()
mock_get_url.return_value = None
response = self.client.get(url)
self.assertNotIn("You are not currently enrolled in this course", response.content)
def test_instructor_page_access_nonstaff(self): def test_instructor_page_access_nonstaff(self):
""" """
......
...@@ -54,6 +54,7 @@ from openedx.core.lib.gating import api as gating_api ...@@ -54,6 +54,7 @@ from openedx.core.lib.gating import api as gating_api
from openedx.core.djangoapps.crawlers.models import CrawlersConfig from openedx.core.djangoapps.crawlers.models import CrawlersConfig
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory
from util.tests.mixins.enterprise import EnterpriseTestConsentRequired
from util.tests.test_date_utils import fake_ugettext, fake_pgettext from util.tests.test_date_utils import fake_ugettext, fake_pgettext
from util.url import reload_django_url_config from util.url import reload_django_url_config
from util.views import ensure_valid_course_key from util.views import ensure_valid_course_key
...@@ -2201,3 +2202,28 @@ class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase): ...@@ -2201,3 +2202,28 @@ class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase):
# Make sure we get back an actual 200, and aren't redirected because we # Make sure we get back an actual 200, and aren't redirected because we
# messed up the setup somehow (e.g. didn't enroll properly) # messed up the setup somehow (e.g. didn't enroll properly)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@attr(shard=1)
class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ModuleStoreTestCase):
"""
Ensure that the Enterprise Data Consent redirects are in place only when consent is required.
"""
def setUp(self):
super(EnterpriseConsentTestCase, self).setUp()
self.user = UserFactory.create()
self.assertTrue(self.client.login(username=self.user.username, password='test'))
self.course = CourseFactory.create()
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
def test_consent_required(self):
"""
Test that enterprise data sharing consent is required when enabled for the various courseware views.
"""
course_id = unicode(self.course.id)
for url in (
reverse("courseware", kwargs=dict(course_id=course_id)),
reverse("progress", kwargs=dict(course_id=course_id)),
reverse("student_progress", kwargs=dict(course_id=course_id, student_id=str(self.user.id))),
):
self.verify_consent_required(self.client, url)
...@@ -31,7 +31,7 @@ from shoppingcart.models import CourseRegistrationCode ...@@ -31,7 +31,7 @@ from shoppingcart.models import CourseRegistrationCode
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.views import is_course_blocked from student.views import is_course_blocked
from student.roles import GlobalStaff from student.roles import GlobalStaff
from util.enterprise_helpers import consent_needed_for_course, get_course_specific_consent_url from util.enterprise_helpers import get_enterprise_consent_url
from util.views import ensure_valid_course_key from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.x_module import STUDENT_VIEW from xmodule.x_module import STUDENT_VIEW
...@@ -203,13 +203,14 @@ class CoursewareIndex(View): ...@@ -203,13 +203,14 @@ class CoursewareIndex(View):
the course, and redirect the user to provide consent if needed. the course, and redirect the user to provide consent if needed.
""" """
course_id = unicode(self.course_key) course_id = unicode(self.course_key)
if consent_needed_for_course(self.real_user, course_id): consent_url = get_enterprise_consent_url(self.request, course_id, user=self.real_user, return_to='courseware')
if consent_url:
log.warning( log.warning(
u'User %s cannot access the course %s because they have not granted consent', u'User %s cannot access the course %s because they have not granted consent',
self.real_user, self.real_user,
course_id, course_id,
) )
raise Redirect(get_course_specific_consent_url(self.request, course_id, 'courseware')) raise Redirect(consent_url)
def _redirect_if_needed_to_pay_for_course(self): def _redirect_if_needed_to_pay_for_course(self):
""" """
......
...@@ -93,7 +93,7 @@ from student.roles import GlobalStaff ...@@ -93,7 +93,7 @@ from student.roles import GlobalStaff
from util.cache import cache, cache_if_anonymous from util.cache import cache, cache_if_anonymous
from util.date_utils import strftime_localized from util.date_utils import strftime_localized
from util.db import outer_atomic from util.db import outer_atomic
from util.enterprise_helpers import consent_needed_for_course, get_course_specific_consent_url from util.enterprise_helpers import get_enterprise_consent_url
from util.milestones_helpers import get_prerequisite_courses_display from util.milestones_helpers import get_prerequisite_courses_display
from util.views import _record_feedback_in_zendesk from util.views import _record_feedback_in_zendesk
from util.views import ensure_valid_course_key, ensure_valid_usage_key from util.views import ensure_valid_course_key, ensure_valid_usage_key
...@@ -330,8 +330,9 @@ def course_info(request, course_id): ...@@ -330,8 +330,9 @@ def course_info(request, course_id):
# If the user is sponsored by an enterprise customer, and we still need to get data # If the user is sponsored by an enterprise customer, and we still need to get data
# sharing consent, redirect to do that first. # sharing consent, redirect to do that first.
if consent_needed_for_course(user, course_id): consent_url = get_enterprise_consent_url(request, course_id, user=user, return_to='info')
return redirect(get_course_specific_consent_url(request, course_id, 'info')) if consent_url:
return redirect(consent_url)
# If the user needs to take an entrance exam to access this course, then we'll need # If the user needs to take an entrance exam to access this course, then we'll need
# to send them to that specific course module before allowing them into other areas # to send them to that specific course module before allowing them into other areas
...@@ -818,8 +819,9 @@ def _progress(request, course_key, student_id): ...@@ -818,8 +819,9 @@ def _progress(request, course_key, student_id):
# If the user is sponsored by an enterprise customer, and we still need to get data # If the user is sponsored by an enterprise customer, and we still need to get data
# sharing consent, redirect to do that first. # sharing consent, redirect to do that first.
if consent_needed_for_course(request.user, unicode(course.id)): consent_url = get_enterprise_consent_url(request, unicode(course.id), return_to='progress')
return redirect(get_course_specific_consent_url(request, unicode(course.id), 'progress')) if consent_url:
return redirect(consent_url)
# check to see if there is a required survey that must be taken before # check to see if there is a required survey that must be taken before
# the user can access the course. # the user can access the course.
......
...@@ -23,6 +23,7 @@ from django_comment_client.utils import strip_none ...@@ -23,6 +23,7 @@ from django_comment_client.utils import strip_none
from lms.djangoapps.discussion import views from lms.djangoapps.discussion import views
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from util.tests.mixins.enterprise import EnterpriseTestConsentRequired
from openedx.core.djangoapps.util.testing import ContentGroupTestCase from openedx.core.djangoapps.util.testing import ContentGroupTestCase
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -356,16 +357,26 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): ...@@ -356,16 +357,26 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase):
# course is outside the context manager that is verifying the number of queries, # course is outside the context manager that is verifying the number of queries,
# and with split mongo, that method ends up querying disabled_xblocks (which is then # and with split mongo, that method ends up querying disabled_xblocks (which is then
# cached and hence not queried as part of call_single_thread). # cached and hence not queried as part of call_single_thread).
(ModuleStoreEnum.Type.mongo, 1, 5, 3, 13, 1), (ModuleStoreEnum.Type.mongo, False, 1, 5, 3, 13, 1),
(ModuleStoreEnum.Type.mongo, 50, 5, 3, 13, 1), (ModuleStoreEnum.Type.mongo, False, 50, 5, 3, 13, 1),
# split mongo: 3 queries, regardless of thread response size. # split mongo: 3 queries, regardless of thread response size.
(ModuleStoreEnum.Type.split, 1, 3, 3, 12, 1), (ModuleStoreEnum.Type.split, False, 1, 3, 3, 12, 1),
(ModuleStoreEnum.Type.split, 50, 3, 3, 12, 1), (ModuleStoreEnum.Type.split, False, 50, 3, 3, 12, 1),
# Enabling Enterprise integration increases the number of (cached and uncached) SQL queries by 1,
# because the presence of the user's consent for the course must be checked.
# But there should be no effect on the number of mongo queries made.
(ModuleStoreEnum.Type.mongo, True, 1, 5, 3, 14, 2),
(ModuleStoreEnum.Type.mongo, True, 50, 5, 3, 14, 2),
# split mongo: 3 queries, regardless of thread response size.
(ModuleStoreEnum.Type.split, True, 1, 3, 3, 13, 2),
(ModuleStoreEnum.Type.split, True, 50, 3, 3, 13, 2),
) )
@ddt.unpack @ddt.unpack
def test_number_of_mongo_queries( def test_number_of_mongo_queries(
self, self,
default_store, default_store,
enterprise_enabled,
num_thread_responses, num_thread_responses,
num_uncached_mongo_calls, num_uncached_mongo_calls,
num_cached_mongo_calls, num_cached_mongo_calls,
...@@ -393,12 +404,13 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): ...@@ -393,12 +404,13 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase):
""" """
Call single_thread and assert that it returns what we expect. Call single_thread and assert that it returns what we expect.
""" """
response = views.single_thread( with override_settings(ENABLE_ENTERPRISE_INTEGRATION=enterprise_enabled):
request, response = views.single_thread(
course.id.to_deprecated_string(), request,
"dummy_discussion_id", course.id.to_deprecated_string(),
test_thread_id "dummy_discussion_id",
) test_thread_id
)
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertEquals(len(json.loads(response.content)["content"]["children"]), num_thread_responses) self.assertEquals(len(json.loads(response.content)["content"]["children"]), num_thread_responses)
...@@ -1566,3 +1578,47 @@ class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase): ...@@ -1566,3 +1578,47 @@ class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase):
request.user = self.student request.user = self.student
with self.assertRaises(UserNotEnrolled): with self.assertRaises(UserNotEnrolled):
views.forum_form_discussion(request, course_id=self.course.id.to_deprecated_string()) views.forum_form_discussion(request, course_id=self.course.id.to_deprecated_string())
@patch('requests.request', autospec=True)
class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Ensure that the Enterprise Data Consent redirects are in place only when consent is required.
"""
CREATE_USER = False
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
# Invoke UrlResetMixin setUp
super(EnterpriseConsentTestCase, self).setUp()
username = "foo"
password = "bar"
self.discussion_id = 'dummy_discussion_id'
self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': self.discussion_id}})
self.student = UserFactory.create(username=username, password=password)
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
self.assertTrue(
self.client.login(username=username, password=password)
)
self.addCleanup(translation.deactivate)
def test_consent_required(self, mock_request):
"""
Test that enterprise data sharing consent is required when enabled for the various discussion views.
"""
thread_id = 'dummy'
course_id = unicode(self.course.id)
mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy', thread_id=thread_id)
for url in (
reverse('discussion.views.forum_form_discussion',
kwargs=dict(course_id=course_id)),
reverse('discussion.views.inline_discussion',
kwargs=dict(course_id=course_id, discussion_id=self.discussion_id)),
reverse('discussion.views.single_thread',
kwargs=dict(course_id=course_id, discussion_id=self.discussion_id, thread_id=thread_id)),
):
self.verify_consent_required(self.client, url)
...@@ -47,6 +47,7 @@ from django_comment_client.utils import ( ...@@ -47,6 +47,7 @@ from django_comment_client.utils import (
) )
import django_comment_client.utils as utils import django_comment_client.utils as utils
import lms.lib.comment_client as cc import lms.lib.comment_client as cc
from util.enterprise_helpers import data_sharing_consent_required
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -179,6 +180,7 @@ def use_bulk_ops(view_func): ...@@ -179,6 +180,7 @@ def use_bulk_ops(view_func):
@login_required @login_required
@data_sharing_consent_required
@use_bulk_ops @use_bulk_ops
def inline_discussion(request, course_key, discussion_id): def inline_discussion(request, course_key, discussion_id):
""" """
...@@ -214,6 +216,7 @@ def inline_discussion(request, course_key, discussion_id): ...@@ -214,6 +216,7 @@ def inline_discussion(request, course_key, discussion_id):
@login_required @login_required
@data_sharing_consent_required
@use_bulk_ops @use_bulk_ops
def forum_form_discussion(request, course_key): def forum_form_discussion(request, course_key):
""" """
...@@ -256,6 +259,7 @@ def forum_form_discussion(request, course_key): ...@@ -256,6 +259,7 @@ def forum_form_discussion(request, course_key):
@require_GET @require_GET
@login_required @login_required
@data_sharing_consent_required
@use_bulk_ops @use_bulk_ops
def single_thread(request, course_key, discussion_id, thread_id): def single_thread(request, course_key, discussion_id, thread_id):
""" """
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment