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."""