Skip to content

Commit d36d3de

Browse files
pwnage101claude
andcommitted
feat: decouple courseware consent and start-date from enterprise
CoursewareViewStarted drives redirect URLs including consent. CourseStartDateValidationFailed swaps the enterprise-specific start-date error for a generic one. ENT-11544 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 03920db commit d36d3de

15 files changed

Lines changed: 187 additions & 397 deletions

File tree

lms/djangoapps/course_home_api/course_metadata/tests/test_views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,10 @@ def test_course_access(
228228
self.update_masquerade(role=masquerade_role)
229229

230230
consent_url = 'dump/consent/url' if dsc_required else None
231-
with patch('openedx.features.enterprise_support.api.get_enterprise_consent_url', return_value=consent_url):
231+
with patch(
232+
'openedx_filters.learning.filters.CoursewareViewStarted.run_filter',
233+
return_value=(consent_url, None, None),
234+
):
232235
response = self.client.get(self.url)
233236

234237
self._assert_course_access_response(response, expect_course_access, error_code)

lms/djangoapps/course_wiki/middleware.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
from django.http import Http404
99
from django.shortcuts import redirect
1010
from django.utils.deprecation import MiddlewareMixin
11+
from openedx_filters.learning.filters import CoursewareViewStarted
1112
from wiki.models import reverse
1213

1314
from common.djangoapps.student.models import CourseEnrollment
1415
from lms.djangoapps.courseware.access import has_access
1516
from lms.djangoapps.courseware.courses import get_course_overview_with_access, get_course_with_access
1617
from openedx.core.lib.request_utils import course_id_from_url
17-
from openedx.features.enterprise_support.api import get_enterprise_consent_url
1818
from xmodule.modulestore.django import modulestore
1919

2020

@@ -96,10 +96,14 @@ def process_view(self, request, view_func, view_args, view_kwargs): # pylint: d
9696
# we'll redirect them to the course about page
9797
return redirect('about_course', str(course_id))
9898

99-
# If we need enterprise data sharing consent for this course, then redirect to the form.
100-
consent_url = get_enterprise_consent_url(request, str(course_id), source='WikiAccessMiddleware')
101-
if consent_url:
102-
return redirect(consent_url)
99+
# If a plugin requires a redirect for this course, redirect now.
100+
redirect_url, _, _ = CoursewareViewStarted.run_filter(
101+
redirect_url=None,
102+
request=request,
103+
course_key=course_id,
104+
)
105+
if redirect_url:
106+
return redirect(redirect_url)
103107

104108
# set the course onto here so that the wiki template can show the course navigation
105109
request.course = course

lms/djangoapps/course_wiki/tests/tests.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,19 @@
33
"""
44

55

6-
from unittest.mock import patch
6+
from unittest.mock import MagicMock, patch
77

88
from django.urls import reverse
99

1010
from lms.djangoapps.courseware.tests.tests import LoginEnrollmentTestCase
1111
from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url
12-
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
1312
from xmodule.modulestore.tests.django_utils import (
1413
ModuleStoreTestCase, # pylint: disable=wrong-import-order
1514
)
1615
from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order
1716

1817

19-
class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCase, ModuleStoreTestCase):
18+
class WikiRedirectTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
2019
"""
2120
Tests for wiki course redirection.
2221
"""
@@ -205,27 +204,33 @@ def test_create_wiki_with_long_course_id(self):
205204
assert resp.status_code == 200
206205

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

216215
# Public wikis can be accessed by non-enrolled users, and so direct access is not gated by the consent page
217216
course = CourseFactory.create()
218217
course.allow_public_wiki_access = False
219218
course.save()
220219

221-
# However, for private wikis, enrolled users must pass through the consent gate
220+
# However, for private wikis, enrolled users must pass through the filter redirect gate
222221
# (Unenrolled users are redirected to course/about)
223222
course_id = str(course.id)
224223
self.login(self.student, self.password)
225224
self.enroll(course)
226225

227-
for (url, status_code) in (
228-
(reverse('course_wiki', kwargs={'course_id': course_id}), 302),
229-
(f'/courses/{course_id}/wiki/', 200),
230-
):
231-
self.verify_consent_required(self.client, url, status_code=status_code) # pylint: disable=no-value-for-parameter
226+
# The course_wiki view is decorated with courseware_view_hooks which calls the filter
227+
url = reverse('course_wiki', kwargs={'course_id': course_id})
228+
response = self.client.get(url)
229+
self.assertEqual(response.status_code, 302)
230+
self.assertEqual(response['Location'], redirect_url)
231+
232+
# The wiki middleware (/courses/.../wiki/) also calls the filter
233+
url = f'/courses/{course_id}/wiki/'
234+
response = self.client.get(url)
235+
self.assertEqual(response.status_code, 302)
236+
self.assertEqual(response['Location'], redirect_url)

lms/djangoapps/course_wiki/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
from wiki.models import Article, URLPath
1515

1616
from lms.djangoapps.course_wiki.utils import course_wiki_slug
17+
from lms.djangoapps.courseware.decorators import courseware_view_hooks
1718
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
1819
from openedx.core.djangolib.markup import Text
1920
from openedx.core.lib.courses import get_course_by_id
20-
from openedx.features.enterprise_support.api import data_sharing_consent_required
2121

2222
log = logging.getLogger(__name__)
2323

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

3333

34-
@data_sharing_consent_required
34+
@courseware_view_hooks
3535
def course_wiki_redirect(request, course_id, wiki_path=""):
3636
"""
3737
This redirects to whatever page on the wiki that the course designates

lms/djangoapps/courseware/access_response.py

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -144,31 +144,13 @@ def __init__(self, start_date, display_error_to_user=True):
144144
)
145145

146146

147-
class StartDateEnterpriseLearnerError(AccessError):
147+
class StartDateFiltersError(AccessError):
148148
"""
149-
Access denied because the course has not started yet and the user is not staff. Use this error when this user is
150-
also an enterprise learner and enrolled in the requested course.
149+
Access denied because the course has not started yet and a plugin requested a more
150+
specific error payload via the ``CourseStartDateValidationFailed`` filter.
151151
"""
152-
def __init__(self, start_date, display_error_to_user=True):
153-
"""
154-
Arguments:
155-
display_error_to_user: If True, display this error to users in the UI.
156-
"""
157-
error_code = "course_not_started_enterprise_learner"
158-
if start_date == DEFAULT_START_DATE:
159-
developer_message = "Course has not started, and the learner is enrolled via an enterprise subsidy."
160-
user_message = _("Course has not started")
161-
else:
162-
developer_message = (
163-
f"Course does not start until {start_date}, and the learner is enrolled via an enterprise subsidy."
164-
)
165-
user_message = _("Course does not start until {}" # pylint: disable=translation-of-non-string
166-
.format(f"{start_date:%B %d, %Y}"))
167-
super().__init__(
168-
error_code,
169-
developer_message,
170-
user_message if display_error_to_user else None
171-
)
152+
def __init__(self, error_code, developer_message, user_message):
153+
super().__init__(error_code, developer_message, user_message)
172154

173155

174156
class MilestoneAccessError(AccessError):

lms/djangoapps/courseware/access_utils.py

Lines changed: 25 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from crum import get_current_request
1010
from django.conf import settings
11-
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
1211
from pytz import UTC
1312

1413
from common.djangoapps.student.models import CourseEnrollment
@@ -19,14 +18,14 @@
1918
DataSharingConsentRequiredAccessError,
2019
EnrollmentRequiredAccessError,
2120
IncorrectActiveEnterpriseAccessError,
22-
StartDateEnterpriseLearnerError,
2321
StartDateError,
22+
StartDateFiltersError
2423
)
2524
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
2625
from openedx.features.course_experience import (
2726
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
2827
COURSE_PRE_START_ACCESS_FLAG,
29-
ENFORCE_MASQUERADE_START_DATES,
28+
ENFORCE_MASQUERADE_START_DATES
3029
)
3130
from xmodule.course_block import COURSE_VISIBILITY_PUBLIC # pylint: disable=wrong-import-order
3231

@@ -70,60 +69,6 @@ def adjust_start_date(user, days_early_for_beta, start, course_key):
7069
return start
7170

7271

73-
def enterprise_learner_enrolled(request, user, course_key):
74-
"""
75-
Determine if the learner should be redirected to the enterprise learner portal by checking their enterprise
76-
memberships/enrollments. If all of the following are true, then we are safe to redirect the learner:
77-
78-
* The learner is linked to an enterprise customer,
79-
* The enterprise customer has subsidized the learner's enrollment in the requested course,
80-
* The enterprise customer has the learner portal enabled.
81-
82-
NOTE: This function MUST be called from a view, or it will throw an exception.
83-
84-
Args:
85-
request (django.http.HttpRequest): The current request being handled. Must not be None.
86-
user (User): The requesting enter, potentially an enterprise learner.
87-
course_key (str): The requested course to check for enrollment.
88-
89-
Returns:
90-
bool: True if the learner is enrolled via a linked enterprise customer and can safely be redirected to the
91-
enterprise learner dashboard.
92-
"""
93-
from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data
94-
95-
if not user.is_authenticated:
96-
return False
97-
98-
# enterprise_customer_data is either None (if learner is not linked to any customer) or a serialized
99-
# EnterpriseCustomer representing the learner's active linked customer.
100-
enterprise_customer_data = enterprise_customer_from_session_or_learner_data(request)
101-
learner_portal_enabled = enterprise_customer_data and enterprise_customer_data["enable_learner_portal"]
102-
if not learner_portal_enabled:
103-
return False
104-
105-
# Additionally make sure the enterprise learner is actually enrolled in the requested course, subsidized
106-
# via the discovered customer.
107-
enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter(
108-
course_id=course_key,
109-
enterprise_customer_user__user_id=user.id,
110-
enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_data["uuid"],
111-
)
112-
enterprise_enrollment_exists = enterprise_enrollments.exists()
113-
log.info(
114-
(
115-
"[enterprise_learner_enrolled] Checking for an enterprise enrollment for "
116-
"lms_user_id=%s in course_key=%s via enterprise_customer_uuid=%s. "
117-
"Exists: %s"
118-
),
119-
user.id,
120-
course_key,
121-
enterprise_customer_data["uuid"],
122-
enterprise_enrollment_exists,
123-
)
124-
return enterprise_enrollment_exists
125-
126-
12772
def check_start_date(user, days_early_for_beta, start, course_key, display_error_to_user=True, now=None):
12873
"""
12974
Verifies whether the given user is allowed access given the
@@ -133,8 +78,9 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error
13378
display_error_to_user: If True, display this error to users in the UI.
13479
13580
Returns:
136-
AccessResponse: Either ACCESS_GRANTED or StartDateError.
81+
AccessResponse: Either ACCESS_GRANTED, StartDateError, or StartDateFiltersError.
13782
"""
83+
from openedx_filters.learning.filters import CourseStartDateValidationFailed
13884
start_dates_disabled = settings.FEATURES["DISABLE_START_DATES"]
13985
masquerading_as_student = is_masquerading_as_student(user, course_key)
14086

@@ -155,11 +101,18 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error
155101
if should_grant_access:
156102
return ACCESS_GRANTED
157103

158-
# Before returning a StartDateError, determine if the learner should be redirected to the enterprise learner
159-
# portal by returning StartDateEnterpriseLearnerError instead.
104+
# Before returning a StartDateError, give plugins a chance to substitute a more specific access-error payload.
160105
request = get_current_request()
161-
if request and enterprise_learner_enrolled(request, user, course_key):
162-
return StartDateEnterpriseLearnerError(start, display_error_to_user=display_error_to_user)
106+
if request is not None:
107+
error_code, developer_message, user_message, _, _ = CourseStartDateValidationFailed.run_filter(
108+
error_code=None,
109+
developer_message=None,
110+
user_message=None,
111+
request=request,
112+
course_key=course_key,
113+
)
114+
if error_code is not None:
115+
return StartDateFiltersError(error_code, developer_message, user_message)
163116

164117
return StartDateError(start, display_error_to_user=display_error_to_user)
165118

@@ -232,22 +185,20 @@ def check_public_access(course, visibilities):
232185

233186
def check_data_sharing_consent(course_id):
234187
"""
235-
Grants access if the user is do not need DataSharing consent, otherwise returns data sharing link.
188+
Grants access if no courseware redirect is pending for this course; otherwise returns an access error.
236189
237190
Returns:
238191
AccessResponse: Either ACCESS_GRANTED or DataSharingConsentRequiredAccessError
239192
"""
240-
from openedx.features.enterprise_support.api import get_enterprise_consent_url
241-
242-
consent_url = get_enterprise_consent_url(
243-
request=get_current_request(),
244-
course_id=str(course_id),
245-
return_to="courseware",
246-
enrollment_exists=True,
247-
source="CoursewareAccess",
193+
from openedx_filters.learning.filters import CoursewareViewStarted
194+
request = get_current_request()
195+
if not request:
196+
return ACCESS_GRANTED
197+
redirect_url, _, _ = CoursewareViewStarted.run_filter(
198+
redirect_url=None, request=request, course_key=course_id,
248199
)
249-
if consent_url:
250-
return DataSharingConsentRequiredAccessError(consent_url=consent_url)
200+
if redirect_url:
201+
return DataSharingConsentRequiredAccessError(consent_url=redirect_url)
251202
return ACCESS_GRANTED
252203

253204

@@ -259,6 +210,7 @@ def check_correct_active_enterprise_customer(user, course_id):
259210
Returns:
260211
AccessResponse: Either ACCESS_GRANTED or IncorrectActiveEnterpriseAccessError
261212
"""
213+
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
262214
enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter(
263215
course_id=course_id, enterprise_customer_user__user_id=user.id
264216
)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
Decorators for courseware views.
3+
"""
4+
import functools
5+
6+
from django.shortcuts import redirect
7+
from opaque_keys.edx.keys import CourseKey
8+
from openedx_filters.learning.filters import CoursewareViewStarted
9+
10+
11+
def courseware_view_hooks(view_func):
12+
"""
13+
Decorator that calls the CoursewareViewStarted filter before rendering a courseware view.
14+
15+
If any pipeline step returns a non-None ``redirect_url``, the user is redirected to
16+
that URL. Otherwise, the original view is rendered normally.
17+
18+
Usage::
19+
20+
@courseware_view_hooks
21+
def my_view(request, course_id, ...):
22+
...
23+
24+
Works with both function-based views and ``method_decorator``-wrapped class-based views.
25+
The decorator extracts the ``course_id`` or ``course_key`` from the view arguments.
26+
"""
27+
@functools.wraps(view_func)
28+
def _wrapper(request_or_self, *args, **kwargs):
29+
# Support both function views (request as first arg) and method views
30+
# (self as first arg, request as second arg).
31+
if hasattr(request_or_self, 'method'):
32+
# Function-based view: first arg is request
33+
request = request_or_self
34+
else:
35+
# Class-based view via method_decorator: first arg is self, second is request
36+
request = args[0] if args else kwargs.get('request')
37+
38+
course_id = kwargs.get('course_id') or (args[0] if args and not hasattr(request_or_self, 'method') else None)
39+
try:
40+
course_key = CourseKey.from_string(str(course_id)) if course_id else None
41+
except Exception: # pylint: disable=broad-except
42+
course_key = None
43+
44+
if course_key is not None:
45+
redirect_url, _request, _course_key = CoursewareViewStarted.run_filter(
46+
redirect_url=None,
47+
request=request,
48+
course_key=course_key,
49+
)
50+
if redirect_url:
51+
return redirect(redirect_url)
52+
53+
return view_func(request_or_self, *args, **kwargs)
54+
55+
return _wrapper

0 commit comments

Comments
 (0)