Skip to content

Commit a3e5b58

Browse files
committed
feat: add courseware-access pipeline steps
This commit replaces most enterprise-specific logic serving the purpose of conditionally blocking courseware access. Enterprise steps added: - EnterpriseStartDateAccessFailureStep supplies an enterprise-specific start-date error for enterprise learners. - ActiveEnterpriseCheckStep denies access when the learner's active EnterpriseCustomer differs from the enrollment's customer. Consent steps added: - DataSharingConsentRedirectStep supplies a redirect URL to collect data sharing consent when it is required. - DataSharingConsentCourseAccessStep denies access when consent is required. ENT-11544
1 parent 2ecc601 commit a3e5b58

19 files changed

Lines changed: 1064 additions & 13 deletions

File tree

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Unreleased
1717
----------
1818
* nothing unreleased
1919

20+
[8.1.0] - 2026-06-17
21+
---------------------
22+
* feat: Add the ``enable_credit_and_industry_pathways`` field for EnterpriseCustomer
23+
2024
[8.0.19] - 2026-06-16
2125
---------------------
2226
* feat: Add the ``enable_credit_and_industry_pathways`` field for EnterpriseCustomer

consent/filters/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Pipeline steps registered against openedx-filters by the Consent app."""

consent/filters/courseware.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
Pipeline steps for courseware filters, contributed by the Consent app.
3+
4+
DataSharingConsentRedirectStep and DataSharingConsentCourseAccessStep both enforce the
5+
same consent requirement but are registered against different platform hooks that fire
6+
at different points in the courseware experience. The two hooks may sometimes run in
7+
the same request, but not always — so both registrations are necessary to ensure
8+
consent is enforced across all entry points.
9+
"""
10+
import logging
11+
from typing import Any
12+
13+
from crum import get_current_request
14+
from opaque_keys.edx.keys import CourseKey
15+
from openedx_filters.filters import PipelineStep
16+
from openedx_filters.learning.filters import CoursewareAccessChecksRequested, CoursewareViewStarted
17+
18+
from consent.helpers import get_enterprise_consent_url
19+
20+
log = logging.getLogger(__name__)
21+
22+
23+
class DataSharingConsentRedirectStep(PipelineStep):
24+
"""
25+
Redirects the user to the consent page when data sharing consent is required.
26+
27+
Registered against ``org.openedx.learning.courseware.view.started.v1``.
28+
Raises ``CoursewareViewStarted.RedirectToUrl`` to redirect when consent is needed.
29+
If consent is not required, the step is a no-op.
30+
"""
31+
32+
def run_filter(self, course_key: CourseKey, view_name: str) -> dict: # pylint: disable=arguments-differ
33+
request = get_current_request()
34+
log.info(
35+
"DataSharingConsentRedirectStep running: course_key=%s, view_name=%s, user_id=%s",
36+
course_key,
37+
view_name,
38+
request.user.id if request else None,
39+
)
40+
if request is None:
41+
return {"course_key": course_key, "view_name": view_name}
42+
consent_url = get_enterprise_consent_url(
43+
request=request,
44+
course_id=str(course_key),
45+
# An enrollment is assumed to exist if a courseware view has started, so just hard-code the value here.
46+
enrollment_exists=True,
47+
# `source` is tracked in consent DB records so we can audit which view initiated the consent redirect.
48+
source=view_name,
49+
# Omitted kwargs:
50+
# - user: Defaults to request.user, which matches original decorator behavior.
51+
# - return_to: Defaults to request.path, which should already be the courseware view.
52+
)
53+
# Redirect to the consent page if consent is required.
54+
if consent_url:
55+
raise CoursewareViewStarted.RedirectToUrl(
56+
message="Data sharing consent required",
57+
redirect_to=consent_url,
58+
)
59+
# No consent required — pass through.
60+
return {"course_key": course_key, "view_name": view_name}
61+
62+
63+
class DataSharingConsentCourseAccessStep(PipelineStep):
64+
"""
65+
Deny courseware access when data sharing consent is required but not granted.
66+
67+
Registered against ``org.openedx.learning.courseware.access_checks.requested.v1``.
68+
Raises ``CoursewareAccessChecksRequested.PreventCoursewareAccess`` to deny
69+
access when ``get_enterprise_consent_url`` returns a URL.
70+
"""
71+
72+
def run_filter(self, user: Any, course_key: CourseKey) -> dict: # pylint: disable=arguments-differ
73+
log.info(
74+
"DataSharingConsentCourseAccessStep running: user_id=%s, course_key=%s",
75+
user.id,
76+
course_key,
77+
)
78+
request = get_current_request()
79+
if request is None:
80+
return {"user": user, "course_key": course_key}
81+
consent_url = get_enterprise_consent_url(
82+
request=request,
83+
course_id=str(course_key),
84+
# We must pass the given user even though the request already has a user attached. They could differ.
85+
user=user,
86+
# This is always True since the step only runs in course access checks that require an existing enrollment.
87+
enrollment_exists=True,
88+
# After granting consent, make sure to redirect to the courseware view regardless of where they came from.
89+
return_to="courseware",
90+
# Identify as originating from the access check context.
91+
source="CoursewareAccess",
92+
)
93+
# Deny courseware access if consent is required.
94+
if consent_url:
95+
raise CoursewareAccessChecksRequested.PreventCoursewareAccess(
96+
message="Data sharing consent required",
97+
error_code="data_sharing_access_required",
98+
# developer_message carries the consent redirect URL by convention: the Learning MFE treats this message
99+
# as a URL when error_code is "data_sharing_access_required" and performs a client-side redirect.
100+
# See frontend-app-learning/src/shared/access.js.
101+
developer_message=consent_url,
102+
user_message="You must give Data Sharing Consent for the course",
103+
)
104+
# No consent required — pass through.
105+
return {"user": user, "course_key": course_key}

consent/helpers.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,162 @@
22
Helper functions for the Consent application.
33
"""
44

5+
import logging
6+
from urllib.parse import urlencode
7+
8+
from edx_django_utils.cache import TieredCache
9+
510
from django.apps import apps
11+
from django.conf import settings
12+
from django.contrib.sites.models import Site
13+
from django.urls import reverse
614

715
from consent.models import ProxyDataSharingConsent
816
from enterprise.api_client.discovery import get_course_catalog_api_service_client
9-
from enterprise.utils import get_enterprise_customer
17+
from enterprise.utils import get_active_enterprise_customer_user, get_enterprise_customer
18+
19+
# ENT-11576: CONSENT_FAILED_PARAMETER, ConsentApiClient, enterprise_customer_uuid_for_request,
20+
# and get_data_consent_share_cache_key will be migrated from the platform's enterprise_support
21+
# module into edx-enterprise, eliminating these cross-boundary imports.
22+
try:
23+
from openedx.features.enterprise_support.api import (
24+
CONSENT_FAILED_PARAMETER,
25+
ConsentApiClient,
26+
enterprise_customer_uuid_for_request,
27+
)
28+
from openedx.features.enterprise_support.utils import get_data_consent_share_cache_key
29+
except ImportError:
30+
CONSENT_FAILED_PARAMETER = 'consent_failed'
31+
ConsentApiClient = None
32+
enterprise_customer_uuid_for_request = None
33+
get_data_consent_share_cache_key = None
34+
35+
LOGGER = logging.getLogger(__name__)
36+
37+
38+
def consent_needed_for_course(request, user, course_id, enrollment_exists=False):
39+
"""
40+
Determine whether ``user`` must grant data-sharing consent before accessing ``course_id``.
41+
"""
42+
# Consent is never required if the enterprise feature is disabled.
43+
if not getattr(settings, 'ENABLE_ENTERPRISE_INTEGRATION', False):
44+
return False
45+
46+
LOGGER.info(
47+
"[ENTERPRISE DSC] Determining if user [%s] must consent to data sharing for course [%s]",
48+
user.username, course_id,
49+
)
50+
51+
active_enterprise_learner_info = get_active_enterprise_customer_user(user)
52+
if not active_enterprise_learner_info:
53+
LOGGER.info(
54+
"[ENTERPRISE DSC] Consent from user [%s] is not needed for course [%s]. "
55+
"The user is not linked to an enterprise.",
56+
user.username, course_id,
57+
)
58+
return False
59+
60+
active_enterprise_customer = active_enterprise_learner_info.enterprise_customer
61+
62+
consent_cache_key = get_data_consent_share_cache_key(
63+
user.id, course_id, str(active_enterprise_customer.uuid),
64+
)
65+
cached = TieredCache.get_cached_response(consent_cache_key)
66+
if cached.is_found and cached.value == 0:
67+
LOGGER.info(
68+
"[ENTERPRISE DSC] Consent from user [%s] is not needed for course [%s]. "
69+
"The DSC cache was checked and the value was 0.",
70+
user.username, course_id,
71+
)
72+
return False
73+
74+
if not active_enterprise_customer.enable_data_sharing_consent:
75+
LOGGER.info(
76+
"[ENTERPRISE DSC] DSC is disabled for enterprise customer [%s]. "
77+
"Consent from user [%s] is not needed for course [%s]",
78+
active_enterprise_customer.slug, user.username, course_id,
79+
)
80+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
81+
return False
82+
83+
current_enterprise_uuid = enterprise_customer_uuid_for_request(request)
84+
if str(current_enterprise_uuid) != str(active_enterprise_customer.uuid):
85+
LOGGER.info(
86+
'[ENTERPRISE DSC] Enterprise mismatch. USER: [%s], RequestEnterprise: [%s], '
87+
'LearnerEnterprise: [%s]',
88+
user.username, current_enterprise_uuid, active_enterprise_customer.uuid,
89+
)
90+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
91+
return False
92+
93+
enterprise_domain = Site.objects.get(domain=active_enterprise_customer.site.domain)
94+
if enterprise_domain != request.site:
95+
LOGGER.info(
96+
'[ENTERPRISE DSC] Site mismatch. USER: [%s], RequestSite: [%s], '
97+
'LearnerEnterpriseDomain: [%s]',
98+
user.username, request.site, enterprise_domain,
99+
)
100+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
101+
return False
102+
103+
client = ConsentApiClient(user=request.user)
104+
consent_required = client.consent_required(
105+
username=user.username,
106+
course_id=course_id,
107+
enterprise_customer_uuid=current_enterprise_uuid,
108+
enrollment_exists=enrollment_exists,
109+
)
110+
if not consent_required:
111+
LOGGER.info(
112+
"[ENTERPRISE DSC] Consent from user [%s] is not needed for course [%s]. "
113+
"The user's current enterprise does not require data sharing consent.",
114+
user.username, course_id,
115+
)
116+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
117+
return False
118+
119+
LOGGER.info(
120+
"[ENTERPRISE DSC] Consent from user [%s] is needed for course [%s]. "
121+
"The user's current enterprise requires data sharing consent, and it has not been given.",
122+
user.username, course_id,
123+
)
124+
return True
125+
126+
127+
def get_enterprise_consent_url(request, course_id, user=None, return_to=None, enrollment_exists=False, source='lms'):
128+
"""
129+
Build a URL to redirect the user to the data-sharing consent page for a specific course.
130+
131+
Arguments:
132+
request: Django request object.
133+
course_id: Course key/identifier string.
134+
user: user to check for consent. If None, uses ``request.user``.
135+
return_to: url name for the page to return to after consent is granted; defaults to
136+
``request.path``.
137+
enrollment_exists: forwarded to ``consent_needed_for_course``.
138+
source: opaque string identifying the caller, recorded on the consent URL.
139+
"""
140+
user = user or request.user
141+
LOGGER.info(
142+
'Getting enterprise consent url for user [%s] and course [%s].',
143+
user.username,
144+
course_id,
145+
)
146+
if not consent_needed_for_course(request, user, course_id, enrollment_exists=enrollment_exists):
147+
return None
148+
return_path = request.path if return_to is None else reverse(return_to, args=(course_id,))
149+
url_params = {
150+
'enterprise_customer_uuid': enterprise_customer_uuid_for_request(request),
151+
'course_id': course_id,
152+
'source': source,
153+
'next': request.build_absolute_uri(return_path),
154+
'failure_url': request.build_absolute_uri(
155+
reverse('dashboard') + '?' + urlencode({CONSENT_FAILED_PARAMETER: course_id})
156+
),
157+
}
158+
full_url = reverse('grant_data_sharing_permissions') + '?' + urlencode(url_params)
159+
LOGGER.info('Redirecting to %s to complete data sharing consent', full_url)
160+
return full_url
10161

11162

12163
def get_data_sharing_consent(username, enterprise_customer_uuid, course_id=None, program_uuid=None):

consent/settings/common.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@
66
# enterprise plugin's settings module.
77
from enterprise.settings.common import FiltersConfig, _merge_filters_config
88

9-
CONSENT_FILTERS_CONFIG: FiltersConfig = {}
9+
CONSENT_FILTERS_CONFIG: FiltersConfig = {
10+
"org.openedx.learning.courseware.view.started.v1": {
11+
"fail_silently": False,
12+
"pipeline": ["consent.filters.courseware.DataSharingConsentRedirectStep"],
13+
},
14+
"org.openedx.learning.courseware.access_checks.requested.v1": {
15+
"fail_silently": False,
16+
"pipeline": ["consent.filters.courseware.DataSharingConsentCourseAccessStep"],
17+
},
18+
}
1019

1120

1221
def plugin_settings(settings):

enterprise/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Your project description goes here.
33
"""
44

5-
__version__ = "8.0.19"
5+
__version__ = "8.1.0"

0 commit comments

Comments
 (0)