From 415814afca4f66c2ef64040b839fa285470ae1c1 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 3 Sep 2026 12:10:16 -0400 Subject: [PATCH] feat: replace enterprise support view imports with filter calls ENT-11574 Swaps the direct openedx.features.enterprise_support imports in the support contact-us and enrollment views for calls to the new SupportContactContextRequested / SupportEnrollmentDataRequested openedx-filters, and wires up OPEN_EDX_FILTERS_CONFIG for the two new filter types (edx-enterprise's pipeline steps). Tests now mock the filter call at the view layer; the enterprise-specific pipeline step behavior is covered by edx-enterprise's own test suite. --- lms/djangoapps/support/tests/test_views.py | 114 ++++++++++++-------- lms/djangoapps/support/views/contact_us.py | 6 +- lms/djangoapps/support/views/enrollments.py | 49 ++------- lms/envs/common.py | 16 +++ lms/envs/production.py | 14 +++ 5 files changed, 107 insertions(+), 92 deletions(-) diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 7e2e04ea1f68..e52fa580036b 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -60,11 +60,6 @@ from openedx.core.djangoapps.oauth_dispatch.tests import factories from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.models import CourseDurationLimitConfig -from openedx.features.enterprise_support.api import enterprise_is_enabled -from openedx.features.enterprise_support.tests.factories import ( - EnterpriseCourseEnrollmentFactory, - EnterpriseCustomerUserFactory, -) from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, @@ -72,11 +67,6 @@ ) from xmodule.modulestore.tests.factories import CourseFactory -try: - from consent.models import DataSharingConsent -except ImportError: # pragma: no cover - pass - class SupportViewTestCase(ModuleStoreTestCase): """ @@ -96,6 +86,49 @@ def setUp(self): assert success, 'Could not log in' +class ContactUsViewTests(SupportViewTestCase): + """ + Tests for ContactUsView. + """ + + @override_settings(ZENDESK_URL='https://example.zendesk.com') + @patch('lms.djangoapps.support.views.contact_us.SupportContactContextRequested.run_filter') + def test_tags_run_through_filter_for_authenticated_user(self, mock_run_filter): + """ + For an authenticated user, the tags list is passed through the + SupportContactContextRequested filter, and the filter's return value is used + as the final tags list in the rendered context. + + The enterprise-specific behavior of the filter's pipeline step (edx-enterprise's + SupportContactEnterpriseTagInjector) is covered by edx-enterprise's own test suite. + This view only needs to verify it wires the filter's return value through correctly. + """ + mock_run_filter.return_value = ['LMS', 'enterprise_learner'] + + response = self.client.get(reverse('support:contact_us')) + + assert response.status_code == 200 + mock_run_filter.assert_called_once() + _, call_kwargs = mock_run_filter.call_args + assert call_kwargs['tags'] == ['LMS'] + assert call_kwargs['user'] == self.user + assert b'enterprise_learner' in response.content + + def test_filter_not_called_for_anonymous_user(self): + """ + Anonymous users never reach the enterprise-tagging branch. + """ + self.client.logout() + with override_settings(ZENDESK_URL='https://example.zendesk.com'): + with patch( + 'lms.djangoapps.support.views.contact_us.SupportContactContextRequested.run_filter' + ) as mock_run_filter: + response = self.client.get(reverse('support:contact_us')) + + assert response.status_code == 200 + mock_run_filter.assert_not_called() + + class SupportViewManageUserTests(SupportViewTestCase): """ Base class for support view tests. @@ -359,7 +392,7 @@ def test_get_enrollments(self, search_string_type): ) assert {CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.HONOR, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE} == {mode['slug'] for mode in data[0]['course_modes']} - assert 'enterprise_course_enrollments' not in data[0] + assert data[0]['enterprise_course_enrollments'] == [] assert data[0]['order_number'] == '' assert data[0]['source_system'] == '' @@ -400,52 +433,39 @@ def test_order_source_system_information(self): assert len(data) == 1 assert data[0]['source_system'] == 'commercetools' - @override_settings(ENABLE_ENTERPRISE_INTEGRATION=True) - @enterprise_is_enabled() - def test_get_enrollments_enterprise_enabled(self): + @patch('lms.djangoapps.support.views.enrollments.SupportEnrollmentDataRequested.run_filter') + def test_get_enrollments_with_enterprise_filter(self, mock_run_filter): + """ + Enterprise enrollment data returned by the SupportEnrollmentDataRequested filter + is threaded into each enrollment's 'enterprise_course_enrollments' key. + + The enterprise-specific behavior of the filter's pipeline step (edx-enterprise's + SupportEnterpriseEnrollmentDataInjector) is covered by edx-enterprise's own test suite. + This view only needs to verify it wires the filter's return value through correctly. + """ + course_id = str(self.course.id) + mock_enterprise_enrollment = { + 'course_id': course_id, + 'enterprise_customer_name': 'Test Enterprise', + 'enterprise_customer_user_id': 42, + 'license': None, + 'saved_for_later': False, + 'data_sharing_consent': {'consent_provided': True}, + } + mock_run_filter.return_value = {course_id: [mock_enterprise_enrollment]} + url = reverse( 'support:enrollment_list', kwargs={'username_or_email': self.student.username} ) - - enterprise_customer_user = EnterpriseCustomerUserFactory( - user_id=self.student.id - ) - enterprise_course_enrollment = EnterpriseCourseEnrollmentFactory( - course_id=self.course.id, - enterprise_customer_user=enterprise_customer_user - ) - data_sharing_consent = DataSharingConsent( - course_id=self.course.id, - enterprise_customer=enterprise_customer_user.enterprise_customer, - username=self.student.username, - granted=True - ) - data_sharing_consent.save() - response = self.client.get(url) assert response.status_code == 200 data = json.loads(response.content.decode('utf-8')) assert len(data) == 1 + mock_run_filter.assert_called_once_with(enrollment_data={}, user=self.student) enterprise_course_enrollments_data = data[0]['enterprise_course_enrollments'] - assert len(enterprise_course_enrollments_data) == 1 - expected = { - 'course_id': str(enterprise_course_enrollment.course_id), - 'enterprise_customer_name': enterprise_customer_user.enterprise_customer.name, - 'enterprise_customer_user_id': enterprise_customer_user.id, - 'license': None, - 'saved_for_later': enterprise_course_enrollment.saved_for_later, - 'data_sharing_consent': { - 'username': self.student.username, - 'enterprise_customer_uuid': str(enterprise_customer_user.enterprise_customer_id), - 'exists': data_sharing_consent.exists, - 'consent_provided': data_sharing_consent.granted, - 'consent_required': data_sharing_consent.consent_required(), - 'course_id': str(enterprise_course_enrollment.course_id), - } - } - assert enterprise_course_enrollments_data[0] == expected + assert enterprise_course_enrollments_data == [mock_enterprise_enrollment] @ddt.data( (True, 'Self Paced'), diff --git a/lms/djangoapps/support/views/contact_us.py b/lms/djangoapps/support/views/contact_us.py index acb1dc122e62..082b145b2bf8 100644 --- a/lms/djangoapps/support/views/contact_us.py +++ b/lms/djangoapps/support/views/contact_us.py @@ -7,11 +7,11 @@ from django.http import Http404 from django.shortcuts import redirect from django.views.generic import View +from openedx_filters.learning.filters import SupportContactContextRequested from common.djangoapps.edxmako.shortcuts import marketing_link, render_to_response from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.features.enterprise_support import api as enterprise_api class ContactUsView(View): @@ -47,9 +47,7 @@ def get(self, request): # pylint: disable=missing-function-docstring if request.user.is_authenticated: context['course_id'] = request.session.get('course_id', '') context['user_enrollments'] = CourseEnrollment.enrollments_for_user_with_overviews_preload(request.user) - enterprise_customer = enterprise_api.enterprise_customer_for_request(request) - if enterprise_customer: - tags.append('enterprise_learner') + tags = SupportContactContextRequested.run_filter(tags=tags, request=request, user=request.user) context['tags'] = tags diff --git a/lms/djangoapps/support/views/enrollments.py b/lms/djangoapps/support/views/enrollments.py index d2fde62ca3a7..fabcfa56f7dc 100644 --- a/lms/djangoapps/support/views/enrollments.py +++ b/lms/djangoapps/support/views/enrollments.py @@ -2,7 +2,6 @@ Support tool for changing course enrollments. """ import logging -from collections import defaultdict import markupsafe from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -14,6 +13,7 @@ from django.views.generic import View from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from openedx_filters.learning.filters import SupportEnrollmentDataRequested from rest_framework.generics import GenericAPIView from common.djangoapps.course_modes.models import CourseMode @@ -34,12 +34,6 @@ from openedx.core.djangoapps.enrollments.api import get_enrollment_attributes, get_enrollments, update_enrollment from openedx.core.djangoapps.enrollments.errors import CourseModeNotFoundError from openedx.core.djangoapps.enrollments.serializers import ModeSerializer -from openedx.features.enterprise_support.api import ( - enterprise_enabled, - get_data_sharing_consents, - get_enterprise_course_enrollments, -) -from openedx.features.enterprise_support.serializers import EnterpriseCourseEnrollmentSerializer logger = logging.getLogger(__name__) @@ -70,35 +64,6 @@ class EnrollmentSupportListView(GenericAPIView): # does not specify a serializer class. exclude_from_schema = True - def _enterprise_course_enrollments_by_course_id(self, user): - """ - Returns a dict containing enterprise course enrollments data with - course ids as keys. - """ - enterprise_course_enrollments = get_enterprise_course_enrollments(user) - data_sharing_consents_for_user = get_data_sharing_consents(user) - - enterprise_enrollments_by_course_id = defaultdict(list) - consent_by_course_and_enterprise_customer_id = {} - - # Get data sharing consent for each enterprise enrollment - for consent in data_sharing_consents_for_user: - key = f'{consent.course_id}-{consent.enterprise_customer_id}' - consent_by_course_and_enterprise_customer_id[key] = consent.serialize() - - for enterprise_course_enrollment in enterprise_course_enrollments: - serialized_enterprise_course_enrollment = EnterpriseCourseEnrollmentSerializer( - enterprise_course_enrollment - ).data - course_id = enterprise_course_enrollment.course_id - enterprise_customer_id = enterprise_course_enrollment.enterprise_customer_user.enterprise_customer_id - key = f'{course_id}-{enterprise_customer_id}' - consent = consent_by_course_and_enterprise_customer_id.get(key) - serialized_enterprise_course_enrollment['data_sharing_consent'] = consent - enterprise_enrollments_by_course_id[course_id].append(serialized_enterprise_course_enrollment) - - return enterprise_enrollments_by_course_id - @method_decorator(require_support_permission) def get(self, request, username_or_email): """ @@ -126,11 +91,13 @@ def get(self, request, username_or_email): # Add manual enrollment history, if it exists enrollment['manual_enrollment'] = self.manual_enrollment_data(enrollment, course_key) - if enterprise_enabled(): - enterprise_enrollments_by_course_id = self._enterprise_course_enrollments_by_course_id(user) - for enrollment in enrollments: - enterprise_course_enrollments = enterprise_enrollments_by_course_id.get(enrollment['course_id'], []) - enrollment['enterprise_course_enrollments'] = enterprise_course_enrollments + enterprise_enrollments_by_course_id = SupportEnrollmentDataRequested.run_filter( + enrollment_data={}, user=user + ) + for enrollment in enrollments: + enrollment['enterprise_course_enrollments'] = enterprise_enrollments_by_course_id.get( + enrollment['course_id'], [] + ) return JsonResponse(enrollments) diff --git a/lms/envs/common.py b/lms/envs/common.py index f7a6f15558cb..fbd1312c2477 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3305,3 +3305,19 @@ def _should_send_certificate_events(settings): SSL_AUTH_DN_FORMAT_STRING = ( "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}" ) + +# .. setting_name: OPEN_EDX_FILTERS_CONFIG +# .. setting_default: {} +# .. setting_description: Configuration dict for openedx-filters pipeline steps. +# Keys are filter type strings; values are dicts with 'fail_silently' (bool) and +# 'pipeline' (list of dotted-path strings to PipelineStep subclasses). +OPEN_EDX_FILTERS_CONFIG = { + "org.openedx.learning.support.contact.context.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.support.SupportContactEnterpriseTagInjector"], + }, + "org.openedx.learning.support.enrollment.data.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.support.SupportEnterpriseEnrollmentDataInjector"], + }, +} diff --git a/lms/envs/production.py b/lms/envs/production.py index b13f232f5bb7..70afe11c2436 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -84,6 +84,7 @@ def get_env_setting(setting): 'EVENT_BUS_PRODUCER_CONFIG', 'DEFAULT_FILE_STORAGE', 'STATICFILES_STORAGE', + 'OPEN_EDX_FILTERS_CONFIG', ] }) @@ -519,6 +520,19 @@ def get_env_setting(setting): _YAML_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {}) ) +# Merge OPEN_EDX_FILTERS_CONFIG from YAML into the default defined in common.py. +# Pipeline steps from YAML are appended after steps defined in common.py. +# The fail_silently value from YAML takes precedence over the one in common.py. +for _filter_type, _filter_config in _YAML_TOKENS.get('OPEN_EDX_FILTERS_CONFIG', {}).items(): + if _filter_type in OPEN_EDX_FILTERS_CONFIG: # noqa: F405 + OPEN_EDX_FILTERS_CONFIG[_filter_type]['pipeline'].extend( # noqa: F405 + _filter_config.get('pipeline', []) + ) + if 'fail_silently' in _filter_config: + OPEN_EDX_FILTERS_CONFIG[_filter_type]['fail_silently'] = _filter_config['fail_silently'] # noqa: F405 + else: + OPEN_EDX_FILTERS_CONFIG[_filter_type] = _filter_config # noqa: F405 + ####################################################################################################################### # HEY! Don't add anything to the end of this file. # Add your defaults to common.py instead!