Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions lms/djangoapps/course_wiki/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@


from urllib.parse import urlparse

from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import redirect
from django.utils.deprecation import MiddlewareMixin
from openedx_filters.learning.filters import CoursewareViewRedirectURL
from wiki.models import reverse

from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import get_course_overview_with_access, get_course_with_access
from openedx.core.lib.request_utils import course_id_from_url
from openedx.features.enterprise_support.api import get_enterprise_consent_url
from common.djangoapps.student.models import CourseEnrollment

from xmodule.modulestore.django import modulestore


Expand Down Expand Up @@ -96,10 +96,14 @@ def process_view(self, request, view_func, view_args, view_kwargs): # lint-amne
# we'll redirect them to the course about page
return redirect('about_course', str(course_id))

# If we need enterprise data sharing consent for this course, then redirect to the form.
consent_url = get_enterprise_consent_url(request, str(course_id), source='WikiAccessMiddleware')
if consent_url:
return redirect(consent_url)
# If a plugin requires a redirect for this course, redirect now.
redirect_urls, _, _ = CoursewareViewRedirectURL.run_filter(
redirect_urls=[],
request=request,
course_key=course_id,
)
if redirect_urls:
return redirect(redirect_urls[0])

# set the course onto here so that the wiki template can show the course navigation
request.course = course
Expand Down
33 changes: 19 additions & 14 deletions lms/djangoapps/course_wiki/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@
"""


from unittest.mock import patch
from unittest.mock import MagicMock, patch
from django.urls import reverse

from lms.djangoapps.courseware.tests.tests import LoginEnrollmentTestCase
from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order


class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCase, ModuleStoreTestCase):
class WikiRedirectTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
"""
Tests for wiki course redirection.
"""
Expand Down Expand Up @@ -202,27 +201,33 @@ def test_create_wiki_with_long_course_id(self):
assert resp.status_code == 200

@patch.dict("django.conf.settings.FEATURES", {'ALLOW_WIKI_ROOT_ACCESS': True})
@patch('openedx.features.enterprise_support.api.enterprise_customer_for_request')
def test_consent_required(self, mock_enterprise_customer_for_request):
@patch('openedx_filters.learning.filters.CoursewareViewRedirectURL.run_filter')
def test_consent_required(self, mock_run_filter):
"""
Test that enterprise data sharing consent is required when enabled for the various courseware views.
Test that wiki views redirect when the CoursewareViewRedirectURL filter provides a URL.
"""
# ENT-924: Temporary solution to replace sensitive SSO usernames.
mock_enterprise_customer_for_request.return_value = None
redirect_url = 'http://example.com/grant_consent'
mock_run_filter.return_value = ([redirect_url], MagicMock(), MagicMock())

# 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
# However, for private wikis, enrolled users must pass through the filter redirect gate
# (Unenrolled users are redirected to course/about)
course_id = str(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),
(f'/courses/{course_id}/wiki/', 200),
):
self.verify_consent_required(self.client, url, status_code=status_code) # lint-amnesty, pylint: disable=no-value-for-parameter
# The course_wiki view is decorated with courseware_view_redirect which calls the filter
url = reverse('course_wiki', kwargs={'course_id': course_id})
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], redirect_url)

# The wiki middleware (/courses/.../wiki/) also calls the filter
url = f'/courses/{course_id}/wiki/'
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], redirect_url)
4 changes: 2 additions & 2 deletions lms/djangoapps/course_wiki/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
from wiki.models import Article, URLPath

from lms.djangoapps.course_wiki.utils import course_wiki_slug
from lms.djangoapps.courseware.decorators import courseware_view_redirect
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangolib.markup import Text
from openedx.core.lib.courses import get_course_by_id
from openedx.features.enterprise_support.api import data_sharing_consent_required

log = logging.getLogger(__name__)

Expand All @@ -31,7 +31,7 @@ def root_create(request):
return redirect('wiki:get', path=root.path)


@data_sharing_consent_required
@courseware_view_redirect
def course_wiki_redirect(request, course_id, wiki_path=""):
"""
This redirects to whatever page on the wiki that the course designates
Expand Down
85 changes: 22 additions & 63 deletions lms/djangoapps/courseware/access_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from crum import get_current_request
from django.conf import settings
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
from pytz import UTC

from common.djangoapps.student.models import CourseEnrollment
Expand All @@ -20,13 +19,13 @@
EnrollmentRequiredAccessError,
IncorrectActiveEnterpriseAccessError,
StartDateEnterpriseLearnerError,
StartDateError,
StartDateError
)
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
from openedx.features.course_experience import (
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
COURSE_PRE_START_ACCESS_FLAG,
ENFORCE_MASQUERADE_START_DATES,
ENFORCE_MASQUERADE_START_DATES
)
from xmodule.course_block import COURSE_VISIBILITY_PUBLIC # lint-amnesty, pylint: disable=wrong-import-order

Expand Down Expand Up @@ -70,58 +69,22 @@ def adjust_start_date(user, days_early_for_beta, start, course_key):
return start


def enterprise_learner_enrolled(request, user, course_key):
def _get_courseware_redirect_url(request, course_key):
"""
Determine if the learner should be redirected to the enterprise learner portal by checking their enterprise
memberships/enrollments. If all of the following are true, then we are safe to redirect the learner:

* The learner is linked to an enterprise customer,
* The enterprise customer has subsidized the learner's enrollment in the requested course,
* The enterprise customer has the learner portal enabled.

NOTE: This function MUST be called from a view, or it will throw an exception.
Return the first courseware redirect URL provided by plugins, or None.

Args:
request (django.http.HttpRequest): The current request being handled. Must not be None.
user (User): The requesting enter, potentially an enterprise learner.
course_key (str): The requested course to check for enrollment.
request (django.http.HttpRequest): The current request.
course_key: The course key for the view being accessed.

Returns:
bool: True if the learner is enrolled via a linked enterprise customer and can safely be redirected to the
enterprise learner dashboard.
str or None: The first redirect URL returned by plugins, or None if no redirect is needed.
"""
from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data

if not user.is_authenticated:
return False

# enterprise_customer_data is either None (if learner is not linked to any customer) or a serialized
# EnterpriseCustomer representing the learner's active linked customer.
enterprise_customer_data = enterprise_customer_from_session_or_learner_data(request)
learner_portal_enabled = enterprise_customer_data and enterprise_customer_data["enable_learner_portal"]
if not learner_portal_enabled:
return False

# Additionally make sure the enterprise learner is actually enrolled in the requested course, subsidized
# via the discovered customer.
enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter(
course_id=course_key,
enterprise_customer_user__user_id=user.id,
enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_data["uuid"],
)
enterprise_enrollment_exists = enterprise_enrollments.exists()
log.info(
(
"[enterprise_learner_enrolled] Checking for an enterprise enrollment for "
"lms_user_id=%s in course_key=%s via enterprise_customer_uuid=%s. "
"Exists: %s"
),
user.id,
course_key,
enterprise_customer_data["uuid"],
enterprise_enrollment_exists,
from openedx_filters.learning.filters import CoursewareViewRedirectURL
redirect_urls, _, _ = CoursewareViewRedirectURL.run_filter(
redirect_urls=[], request=request, course_key=course_key
)
return enterprise_enrollment_exists
return redirect_urls[0] if redirect_urls else None


def check_start_date(user, days_early_for_beta, start, course_key, display_error_to_user=True, now=None):
Expand Down Expand Up @@ -155,10 +118,10 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error
if should_grant_access:
return ACCESS_GRANTED

# Before returning a StartDateError, determine if the learner should be redirected to the enterprise learner
# portal by returning StartDateEnterpriseLearnerError instead.
# Before returning a StartDateError, determine if a plugin requires a redirect (e.g. enterprise learner
# portal), and if so return StartDateEnterpriseLearnerError instead.
request = get_current_request()
if request and enterprise_learner_enrolled(request, user, course_key):
if request and _get_courseware_redirect_url(request, course_key):
return StartDateEnterpriseLearnerError(start, display_error_to_user=display_error_to_user)

return StartDateError(start, display_error_to_user=display_error_to_user)
Expand Down Expand Up @@ -232,22 +195,17 @@ def check_public_access(course, visibilities):

def check_data_sharing_consent(course_id):
"""
Grants access if the user is do not need DataSharing consent, otherwise returns data sharing link.
Grants access if no courseware redirect is pending for this course; otherwise returns an access error.

Returns:
AccessResponse: Either ACCESS_GRANTED or DataSharingConsentRequiredAccessError
"""
from openedx.features.enterprise_support.api import get_enterprise_consent_url

consent_url = get_enterprise_consent_url(
request=get_current_request(),
course_id=str(course_id),
return_to="courseware",
enrollment_exists=True,
source="CoursewareAccess",
)
if consent_url:
return DataSharingConsentRequiredAccessError(consent_url=consent_url)
request = get_current_request()
if not request:
return ACCESS_GRANTED
redirect_url = _get_courseware_redirect_url(request, course_id)
if redirect_url:
return DataSharingConsentRequiredAccessError(consent_url=redirect_url)
return ACCESS_GRANTED


Expand All @@ -259,6 +217,7 @@ def check_correct_active_enterprise_customer(user, course_id):
Returns:
AccessResponse: Either ACCESS_GRANTED or IncorrectActiveEnterpriseAccessError
"""
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter(
course_id=course_id, enterprise_customer_user__user_id=user.id
)
Expand Down
55 changes: 55 additions & 0 deletions lms/djangoapps/courseware/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Decorators for courseware views.
"""
import functools

from django.shortcuts import redirect
from opaque_keys.edx.keys import CourseKey
from openedx_filters.learning.filters import CoursewareViewRedirectURL


def courseware_view_redirect(view_func):
"""
Decorator that calls the CoursewareViewRedirectURL filter before rendering a courseware view.

If any pipeline step returns a non-empty list of redirect URLs, the user is redirected
to the first URL in the list. Otherwise, the original view is rendered normally.

Usage::

@courseware_view_redirect
def my_view(request, course_id, ...):
...

Works with both function-based views and ``method_decorator``-wrapped class-based views.
The decorator extracts the ``course_id`` or ``course_key`` from the view arguments.
"""
@functools.wraps(view_func)
def _wrapper(request_or_self, *args, **kwargs):
# Support both function views (request as first arg) and method views
# (self as first arg, request as second arg).
if hasattr(request_or_self, 'method'):
# Function-based view: first arg is request
request = request_or_self
else:
# Class-based view via method_decorator: first arg is self, second is request
request = args[0] if args else kwargs.get('request')

course_id = kwargs.get('course_id') or (args[0] if args and not hasattr(request_or_self, 'method') else None)
try:
course_key = CourseKey.from_string(str(course_id)) if course_id else None
except Exception: # pylint: disable=broad-except
course_key = None

if course_key is not None:
redirect_urls, _request, _course_key = CoursewareViewRedirectURL.run_filter(
redirect_urls=[],
request=request,
course_key=course_key,
)
if redirect_urls:
return redirect(redirect_urls[0])

return view_func(request_or_self, *args, **kwargs)

return _wrapper
8 changes: 4 additions & 4 deletions lms/djangoapps/courseware/tests/test_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -952,15 +952,15 @@ def test_course_catalog_access_num_queries_enterprise(self, user_attr_name, cour
# read: CourseAccessRole + django_comment_client.Role
num_queries = 2
else:
# read: CourseAccessRole + EnterpriseCourseEnrollment
num_queries = 2
# read: CourseAccessRole
num_queries = 1
elif user_attr_name == 'user_normal':
if course_attr_name == 'course_started':
# read: CourseAccessRole + django_comment_client.Role + FBEEnrollmentExclusion + CourseMode
num_queries = 4
else:
# read: CourseAccessRole + CourseEnrollmentAllowed + EnterpriseCourseEnrollment
num_queries = 3
# read: CourseAccessRole + CourseEnrollmentAllowed
num_queries = 2
elif user_attr_name == 'user_anonymous':
if course_attr_name == 'course_started':
# read: CourseMode
Expand Down
Loading
Loading