From 705e2fdc8e6f595fdd6edd55ff1a593b3f2e5fc0 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 3 Mar 2026 14:53:33 -0800 Subject: [PATCH 1/2] feat: replace enterprise_support import with AccountSettingsReadOnlyFieldsRequested filter Removes the direct import of get_enterprise_readonly_account_fields from openedx.features.enterprise_support.utils in accounts/api.py and replaces it with a call to the AccountSettingsReadOnlyFieldsRequested openedx-filter. Adds the filter to OPEN_EDX_FILTERS_CONFIG. Updates tests to mock the filter instead of the old enterprise_support imports. ENT-11510 Co-Authored-By: Claude Sonnet 4.6 --- lms/envs/common.py | 12 +++ .../core/djangoapps/user_api/accounts/api.py | 11 ++- .../user_api/accounts/tests/test_api.py | 92 ++++--------------- 3 files changed, 39 insertions(+), 76 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 917dd025e96f..7ee06f353dd3 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3172,3 +3172,15 @@ 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.account.settings.read_only_fields.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"], + }, +} diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index c3fd805a3166..8752929dead7 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -5,6 +5,7 @@ import datetime import re +from zoneinfo import ZoneInfo from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -12,7 +13,7 @@ from django.utils.translation import gettext as _ from django.utils.translation import override as override_language from eventtracking import tracker -from zoneinfo import ZoneInfo +from openedx_filters.learning.filters import AccountSettingsReadOnlyFieldsRequested from common.djangoapps.student import views as student_views from common.djangoapps.student.models import ( @@ -38,7 +39,6 @@ from openedx.core.djangoapps.user_authn.utils import check_pwned_password from openedx.core.djangoapps.user_authn.views.registration_form import validate_name, validate_username from openedx.core.lib.api.view_utils import add_serializer_errors -from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed from .serializers import AccountLegacyProfileSerializer, AccountUserSerializer, UserReadOnlySerializer, _visible_fields @@ -193,11 +193,16 @@ def update_account_settings(requesting_user, update, username=None): def _validate_read_only_fields(user, data, field_errors): # Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400. + plugin_readonly_fields = AccountSettingsReadOnlyFieldsRequested.run_filter( + readonly_fields=set(), + user=user, + ) or set() + read_only_fields = set(data.keys()).intersection( # Remove email since it is handled separately below when checking for changing_email. (set(AccountUserSerializer.get_read_only_fields()) - {"email"}) | set(AccountLegacyProfileSerializer.get_read_only_fields() or set()) | - get_enterprise_readonly_account_fields(user) + plugin_readonly_fields ) for read_only_field in read_only_fields: diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 11b2f800f996..f79fcff1bef0 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -4,9 +4,9 @@ """ import datetime -import itertools import unicodedata from unittest.mock import Mock, patch +from zoneinfo import ZoneInfo import ddt import pytest @@ -17,8 +17,6 @@ from django.test import TestCase from django.test.client import RequestFactory from django.urls import reverse -from zoneinfo import ZoneInfo -from social_django.models import UserSocialAuth from common.djangoapps.student.models import ( AccountRecovery, @@ -104,10 +102,12 @@ def setUp(self): self.staff_user = UserFactory(is_staff=True, password=self.password) self.reset_tracker() - enterprise_patcher = patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - enterprise_learner_patcher = enterprise_patcher.start() - enterprise_learner_patcher.return_value = {} - self.addCleanup(enterprise_learner_patcher.stop) + filter_patcher = patch( + 'openedx.core.djangoapps.user_api.accounts.api.AccountSettingsReadOnlyFieldsRequested.run_filter', + return_value=set(), + ) + filter_patcher.start() + self.addCleanup(filter_patcher.stop) def test_get_username_provided(self): """Test the difference in behavior when a username is supplied to get_account_settings.""" @@ -248,73 +248,19 @@ def test_update_success_for_enterprise(self): account_settings = get_account_settings(self.default_request)[0] assert level_of_education == account_settings['level_of_education'] - @patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - @patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') - @ddt.data( - *itertools.product( - # field_name_value values - (("email", "new_email@example.com"), ("name", "new name"), ("country", "IN")), - # is_enterprise_user - (True, False), - # is_synch_learner_profile_data - (True, False), - # has `UserSocialAuth` record - (True, False), - ) + @patch( + 'openedx.core.djangoapps.user_api.accounts.api.AccountSettingsReadOnlyFieldsRequested.run_filter', + return_value={'country'}, ) - @ddt.unpack - def test_update_validation_error_for_enterprise( - self, - field_name_value, - is_enterprise_user, - is_synch_learner_profile_data, - has_user_social_auth_record, - mock_auth_provider, - mock_customer, - ): - idp_backend_name = 'tpa-saml' - mock_customer.return_value = {} - if is_enterprise_user: - mock_customer.return_value.update({ - 'uuid': 'real-ent-uuid', - 'name': 'Dummy Enterprise', - 'identity_provider': 'saml-ubc', - 'identity_providers': [ - { - "provider_id": "saml-ubc", - } - ], - }) - mock_auth_provider.return_value.sync_learner_profile_data = is_synch_learner_profile_data - mock_auth_provider.return_value.backend_name = idp_backend_name - - update_data = {field_name_value[0]: field_name_value[1]} - - user_fullname_editable = False - if has_user_social_auth_record: - UserSocialAuth.objects.create( - provider=idp_backend_name, - user=self.user - ) - else: - UserSocialAuth.objects.all().delete() - # user's fullname is editable if no `UserSocialAuth` record exists - user_fullname_editable = field_name_value[0] == 'name' - - # prevent actual email change requests - with patch('openedx.core.djangoapps.user_api.accounts.api.student_views.do_email_change_request'): - # expect field un-editability only when all of the following conditions are met - if is_enterprise_user and is_synch_learner_profile_data and not user_fullname_editable: - with pytest.raises(AccountValidationError) as validation_error: - update_account_settings(self.user, update_data) - field_errors = validation_error.value.field_errors - assert 'This field is not editable via this API' == \ - field_errors[field_name_value[0]]['developer_message'] - else: - update_account_settings(self.user, update_data) - account_settings = get_account_settings(self.default_request)[0] - if field_name_value[0] != "email": - assert field_name_value[1] == account_settings[field_name_value[0]] + def test_readonly_field_from_filter_is_rejected(self, mock_run_filter): # pylint: disable=unused-argument + """ + When AccountSettingsReadOnlyFieldsRequested.run_filter returns a field as read-only, + update_account_settings should raise AccountValidationError for that field. + """ + with pytest.raises(AccountValidationError) as exc_info: + update_account_settings(self.user, {"country": "IN"}) + field_errors = exc_info.value.field_errors + assert 'This field is not editable via this API' == field_errors['country']['developer_message'] def test_update_error_validating(self): """Test that AccountValidationError is thrown if incorrect values are supplied.""" From 0a7d048a85e97990fa0104a95d55ee9cae26c71f Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:58:19 +0000 Subject: [PATCH 2/2] fix: changes to prevent prod override --- lms/envs/common.py | 23 ++++++++++++----------- lms/envs/production.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 7ee06f353dd3..631fc0090c28 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3160,6 +3160,18 @@ def _should_send_certificate_events(settings): # The project ID should be obtained from the Google Cloud Console when creating a reCAPTCHA RECAPTCHA_PROJECT_ID = None +# .. 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.account.settings.read_only_fields.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"], + }, +} + ############################## Miscellaneous ############################### # To limit the number of courses displayed on learner dashboard @@ -3173,14 +3185,3 @@ def _should_send_certificate_events(settings): "/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.account.settings.read_only_fields.requested.v1": { - "fail_silently": True, - "pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"], - }, -} diff --git a/lms/envs/production.py b/lms/envs/production.py index 63246821d954..51cba88dd3fd 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', ] }) @@ -278,6 +279,19 @@ def get_env_setting(setting): EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST ) +# 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: + OPEN_EDX_FILTERS_CONFIG[_filter_type]['pipeline'].extend( + _filter_config.get('pipeline', []) + ) + if 'fail_silently' in _filter_config: + OPEN_EDX_FILTERS_CONFIG[_filter_type]['fail_silently'] = _filter_config['fail_silently'] + else: + OPEN_EDX_FILTERS_CONFIG[_filter_type] = _filter_config + if ENABLE_THIRD_PARTY_AUTH: AUTHENTICATION_BACKENDS = _YAML_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ 'social_core.backends.google.GoogleOAuth2',