Skip to content

Commit e6bff04

Browse files
pwnage101claude
andcommitted
feat: add courseware-access pipeline steps for DataSharingConsentRedirectStep, EnterpriseStartDateAccessFailureStep, ActiveEnterpriseCheckStep, and DataSharingConsentCourseAccessStep
DataSharingConsentRedirectStep supplies a redirect URL when data sharing consent is required. EnterpriseStartDateAccessFailureStep supplies a start-date error for enterprise learners. ActiveEnterpriseCheckStep denies access when the learner's active EnterpriseCustomer differs from the enrollment's customer. DataSharingConsentCourseAccessStep denies access when consent is required. ENT-11544 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 68d471e commit e6bff04

11 files changed

Lines changed: 743 additions & 4 deletions

File tree

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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Pipeline steps for courseware filters, contributed by the Consent app.
3+
"""
4+
from typing import Optional
5+
6+
from crum import get_current_request
7+
from django.contrib.auth.base_user import AbstractBaseUser
8+
from django.http import HttpRequest
9+
from opaque_keys.edx.keys import CourseKey
10+
from openedx_filters.filters import PipelineStep
11+
from openedx_filters.learning.filters import CoursewareAccessChecksRequested
12+
13+
from consent.helpers import get_enterprise_consent_url
14+
15+
16+
class DataSharingConsentRedirectStep(PipelineStep):
17+
"""
18+
Sets the data-sharing consent redirect URL when the user has not yet consented.
19+
20+
Registered against ``org.openedx.learning.courseware.view.started.v1``.
21+
Earlier steps win: if ``redirect_url`` is already set, this step is a no-op.
22+
"""
23+
24+
def run_filter(
25+
self,
26+
redirect_url: Optional[str],
27+
request: HttpRequest,
28+
course_key: CourseKey,
29+
) -> dict: # pylint: disable=arguments-differ
30+
if redirect_url is None:
31+
consent_url = get_enterprise_consent_url(request, str(course_key))
32+
if consent_url:
33+
redirect_url = consent_url
34+
return {"redirect_url": redirect_url, "request": request, "course_key": course_key}
35+
36+
37+
class DataSharingConsentCourseAccessStep(PipelineStep):
38+
"""
39+
Deny courseware access when data sharing consent is required but not granted.
40+
41+
Registered against ``org.openedx.learning.courseware.access_checks.requested.v1``.
42+
Raises ``CoursewareAccessChecksRequested.PreventCoursewareAccess`` to deny
43+
access when ``get_enterprise_consent_url`` returns a URL.
44+
"""
45+
46+
def run_filter(self, user: AbstractBaseUser, course_key: CourseKey) -> dict: # pylint: disable=arguments-differ
47+
request = get_current_request()
48+
if request is None:
49+
return {"user": user, "course_key": course_key}
50+
consent_url = get_enterprise_consent_url(request, str(course_key))
51+
if consent_url:
52+
raise CoursewareAccessChecksRequested.PreventCoursewareAccess(
53+
error_code="data_sharing_access_required",
54+
developer_message=consent_url,
55+
user_message="You must give Data Sharing Consent for the course",
56+
)
57+
return {"user": user, "course_key": course_key}

consent/helpers.py

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

5+
import logging
6+
from urllib.parse import urlencode
7+
58
from django.apps import apps
9+
from django.conf import settings
10+
from django.contrib.sites.models import Site
11+
from django.urls import reverse
12+
from edx_django_utils.cache import TieredCache
613

714
from consent.models import ProxyDataSharingConsent
815
from enterprise.api_client.discovery import get_course_catalog_api_service_client
9-
from enterprise.utils import get_enterprise_customer
16+
from enterprise.utils import (
17+
_enterprise_integration_enabled,
18+
get_active_enterprise_customer_user,
19+
get_enterprise_customer,
20+
)
21+
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+
if not _enterprise_integration_enabled():
43+
return False
44+
if (
45+
ConsentApiClient is None
46+
or enterprise_customer_uuid_for_request is None
47+
or get_data_consent_share_cache_key is None
48+
):
49+
return False
50+
LOGGER.info(
51+
"[ENTERPRISE DSC] Determining if user [%s] must consent to data sharing for course [%s]",
52+
user.username, course_id,
53+
)
54+
55+
active_enterprise_learner_info = get_active_enterprise_customer_user(user)
56+
if not active_enterprise_learner_info:
57+
LOGGER.info(
58+
"[ENTERPRISE DSC] Consent from user [%s] is not needed for course [%s]. "
59+
"The user is not linked to an enterprise.",
60+
user.username, course_id,
61+
)
62+
return False
63+
64+
active_enterprise_customer = active_enterprise_learner_info['enterprise_customer']
65+
66+
consent_cache_key = get_data_consent_share_cache_key(
67+
user.id, course_id, active_enterprise_customer['uuid'],
68+
)
69+
cached = TieredCache.get_cached_response(consent_cache_key)
70+
if cached.is_found and cached.value == 0:
71+
LOGGER.info(
72+
"[ENTERPRISE DSC] Consent from user [%s] is not needed for course [%s]. "
73+
"The DSC cache was checked and the value was 0.",
74+
user.username, course_id,
75+
)
76+
return False
77+
78+
if not active_enterprise_customer['enable_data_sharing_consent']:
79+
LOGGER.info(
80+
"[ENTERPRISE DSC] DSC is disabled for enterprise customer [%s]. "
81+
"Consent from user [%s] is not needed for course [%s]",
82+
active_enterprise_customer['slug'], user.username, course_id,
83+
)
84+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
85+
return False
86+
87+
current_enterprise_uuid = enterprise_customer_uuid_for_request(request)
88+
if str(current_enterprise_uuid) != str(active_enterprise_customer['uuid']):
89+
LOGGER.info(
90+
'[ENTERPRISE DSC] Enterprise mismatch. USER: [%s], RequestEnterprise: [%s], '
91+
'LearnerEnterprise: [%s]',
92+
user.username, current_enterprise_uuid, active_enterprise_customer['uuid'],
93+
)
94+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
95+
return False
96+
97+
enterprise_domain = Site.objects.get(domain=active_enterprise_customer['site']['domain'])
98+
if enterprise_domain != request.site:
99+
LOGGER.info(
100+
'[ENTERPRISE DSC] Site mismatch. USER: [%s], RequestSite: [%s], '
101+
'LearnerEnterpriseDomain: [%s]',
102+
user.username, request.site, enterprise_domain,
103+
)
104+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
105+
return False
106+
107+
client = ConsentApiClient(user=request.user)
108+
consent_required = client.consent_required(
109+
username=user.username,
110+
course_id=course_id,
111+
enterprise_customer_uuid=current_enterprise_uuid,
112+
enrollment_exists=enrollment_exists,
113+
)
114+
if not consent_required:
115+
LOGGER.info(
116+
"[ENTERPRISE DSC] Consent from user [%s] is not needed for course [%s]. "
117+
"The user's current enterprise does not require data sharing consent.",
118+
user.username, course_id,
119+
)
120+
TieredCache.set_all_tiers(consent_cache_key, 0, settings.DATA_CONSENT_SHARE_CACHE_TIMEOUT)
121+
return False
122+
123+
LOGGER.info(
124+
"[ENTERPRISE DSC] Consent from user [%s] is needed for course [%s]. "
125+
"The user's current enterprise requires data sharing consent, and it has not been given.",
126+
user.username, course_id,
127+
)
128+
return True
129+
130+
131+
def get_enterprise_consent_url(request, course_id, user=None, return_to=None, enrollment_exists=False, source='lms'):
132+
"""
133+
Build a URL to redirect the user to the data-sharing consent page for a specific course.
134+
135+
Arguments:
136+
request: Django request object.
137+
course_id: Course key/identifier string.
138+
user: user to check for consent. If None, uses ``request.user``.
139+
return_to: url name for the page to return to after consent is granted; defaults to
140+
``request.path``.
141+
enrollment_exists: forwarded to ``consent_needed_for_course``.
142+
source: opaque string identifying the caller, recorded on the consent URL.
143+
"""
144+
if enterprise_customer_uuid_for_request is None:
145+
return None
146+
user = user or request.user
147+
LOGGER.info(
148+
'Getting enterprise consent url for user [%s] and course [%s].',
149+
user.username,
150+
course_id,
151+
)
152+
if not consent_needed_for_course(request, user, course_id, enrollment_exists=enrollment_exists):
153+
return None
154+
return_path = request.path if return_to is None else reverse(return_to, args=(course_id,))
155+
url_params = {
156+
'enterprise_customer_uuid': enterprise_customer_uuid_for_request(request),
157+
'course_id': course_id,
158+
'source': source,
159+
'next': request.build_absolute_uri(return_path),
160+
'failure_url': request.build_absolute_uri(
161+
reverse('dashboard') + '?' + urlencode({CONSENT_FAILED_PARAMETER: course_id})
162+
),
163+
}
164+
full_url = reverse('grant_data_sharing_permissions') + '?' + urlencode(url_params)
165+
LOGGER.info('Redirecting to %s to complete data sharing consent', full_url)
166+
return full_url
10167

11168

12169
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": True,
12+
"pipeline": ["consent.filters.courseware.DataSharingConsentRedirectStep"],
13+
},
14+
"org.openedx.learning.courseware.access_checks.requested.v1": {
15+
"fail_silently": True,
16+
"pipeline": ["consent.filters.courseware.DataSharingConsentCourseAccessStep"],
17+
},
18+
}
1019

1120

1221
def plugin_settings(settings):

enterprise/filters/courseware.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""
2+
Pipeline steps for courseware-related openedx-filters contributed by the Enterprise app.
3+
"""
4+
import logging
5+
from typing import Optional
6+
7+
from django.contrib.auth.base_user import AbstractBaseUser
8+
from django.http import HttpRequest
9+
from opaque_keys.edx.keys import CourseKey
10+
from openedx_filters.filters import PipelineStep
11+
from openedx_filters.learning.filters import CoursewareAccessChecksRequested
12+
13+
try:
14+
from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data
15+
except ImportError:
16+
enterprise_customer_from_session_or_learner_data = None
17+
18+
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
19+
20+
log = logging.getLogger(__name__)
21+
22+
23+
class EnterpriseStartDateAccessFailureStep(PipelineStep):
24+
"""
25+
Substitutes a more specific start-date access-error payload for enterprise learners.
26+
27+
Registered against ``org.openedx.learning.course.start_date.validation_failed.v1``.
28+
If ``error_code`` is already set (an earlier step won) or the request user is not
29+
associated with the active session enterprise customer, this step passes through
30+
unchanged.
31+
"""
32+
33+
def run_filter(
34+
self,
35+
error_code: Optional[str],
36+
developer_message: Optional[str],
37+
user_message: Optional[str],
38+
request: HttpRequest,
39+
course_key: CourseKey,
40+
) -> dict: # pylint: disable=arguments-differ
41+
if error_code is None and enterprise_customer_from_session_or_learner_data is not None:
42+
enterprise_customer = enterprise_customer_from_session_or_learner_data(request)
43+
if enterprise_customer:
44+
user = request.user
45+
is_enterprise_learner = EnterpriseCustomerUser.objects.filter(
46+
user_id=user.id,
47+
enterprise_customer__uuid=enterprise_customer.get('uuid'),
48+
).exists()
49+
if is_enterprise_learner:
50+
error_code = "course_not_started_enterprise_learner"
51+
developer_message = (
52+
f"Course {course_key} has not started, and the learner is "
53+
"enrolled via an enterprise subsidy."
54+
)
55+
user_message = "Course has not started"
56+
return {
57+
"error_code": error_code,
58+
"developer_message": developer_message,
59+
"user_message": user_message,
60+
"request": request,
61+
"course_key": course_key,
62+
}
63+
64+
65+
class ActiveEnterpriseCheckStep(PipelineStep):
66+
"""
67+
Deny access when the learner's active EnterpriseCustomer differs from the
68+
EnterpriseCustomer attached to their EnterpriseCourseEnrollment for this course.
69+
70+
Registered against ``org.openedx.learning.courseware.access_checks.requested.v1``.
71+
Raises ``CoursewareAccessChecksRequested.PreventCoursewareAccess`` to deny access.
72+
"""
73+
74+
def run_filter(self, user: AbstractBaseUser, course_key: CourseKey) -> dict: # pylint: disable=arguments-differ
75+
enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter(
76+
course_id=course_key, enterprise_customer_user__user_id=user.id,
77+
)
78+
if not enterprise_enrollments.exists():
79+
return {"user": user, "course_key": course_key}
80+
81+
try:
82+
active_ecu = EnterpriseCustomerUser.objects.get(user_id=user.id, active=True)
83+
if enterprise_enrollments.filter(enterprise_customer_user=active_ecu).exists():
84+
return {"user": user, "course_key": course_key}
85+
active_enterprise_name = active_ecu.enterprise_customer.name
86+
except (EnterpriseCustomerUser.DoesNotExist, EnterpriseCustomerUser.MultipleObjectsReturned):
87+
log.error("Multiple or No Active Enterprise found for the user %s.", user.id)
88+
active_enterprise_name = "Incorrect"
89+
90+
enrollment_enterprise_name = (
91+
enterprise_enrollments.first().enterprise_customer_user.enterprise_customer.name
92+
)
93+
user_message = (
94+
"You are enrolled in this course with '{enrollment_enterprise_name}'. However, you are "
95+
"currently logged in as a '{active_enterprise_name}' user. Please log in with "
96+
"'{enrollment_enterprise_name}' to access this course."
97+
).format(
98+
enrollment_enterprise_name=enrollment_enterprise_name,
99+
active_enterprise_name=active_enterprise_name,
100+
)
101+
raise CoursewareAccessChecksRequested.PreventCoursewareAccess(
102+
error_code="incorrect_active_enterprise",
103+
developer_message=(
104+
"User active enterprise should be same as EnterpriseCourseEnrollment enterprise."
105+
),
106+
user_message=user_message,
107+
)

enterprise/settings/common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020
"fail_silently": False,
2121
"pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"],
2222
},
23+
"org.openedx.learning.course.start_date.validation_failed.v1": {
24+
"fail_silently": True,
25+
"pipeline": ["enterprise.filters.courseware.EnterpriseStartDateAccessFailureStep"],
26+
},
27+
"org.openedx.learning.courseware.access_checks.requested.v1": {
28+
"fail_silently": True,
29+
"pipeline": ["enterprise.filters.courseware.ActiveEnterpriseCheckStep"],
30+
},
2331
}
2432

2533

0 commit comments

Comments
 (0)