Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3695,6 +3695,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,
Comment thread
kiram15 marked this conversation as resolved.
"pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"],
Comment thread
kiram15 marked this conversation as resolved.
},
Comment thread
kiram15 marked this conversation as resolved.
Comment thread
kiram15 marked this conversation as resolved.
}

############################## Miscellaneous ###############################

# To limit the number of courses displayed on learner dashboard
Expand Down
14 changes: 14 additions & 0 deletions lms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def get_env_setting(setting):
'EVENT_BUS_PRODUCER_CONFIG',
'DEFAULT_FILE_STORAGE',
'STATICFILES_STORAGE',
'OPEN_EDX_FILTERS_CONFIG',
]
})

Expand Down Expand Up @@ -281,6 +282,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():
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_YAML_TOKENS.get('OPEN_EDX_FILTERS_CONFIG', {}) will return None if the YAML explicitly sets OPEN_EDX_FILTERS_CONFIG: null, which would raise an AttributeError on .items(). Consider using (_YAML_TOKENS.get('OPEN_EDX_FILTERS_CONFIG') or {}) (and ideally validating it’s a dict) before iterating to avoid startup-time crashes from a misconfigured YAML.

Suggested change
for _filter_type, _filter_config in _YAML_TOKENS.get('OPEN_EDX_FILTERS_CONFIG', {}).items():
_open_edx_filters_config = _YAML_TOKENS.get('OPEN_EDX_FILTERS_CONFIG') or {}
if not isinstance(_open_edx_filters_config, dict):
raise ImproperlyConfigured('OPEN_EDX_FILTERS_CONFIG must be a dict')
for _filter_type, _filter_config in _open_edx_filters_config.items():

Copilot uses AI. Check for mistakes.
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',
Expand Down
10 changes: 8 additions & 2 deletions openedx/core/djangoapps/user_api/accounts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
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 openedx_filters.learning.filters import AccountSettingsReadOnlyFieldsRequested

Comment on lines 39 to 44
from .serializers import AccountLegacyProfileSerializer, AccountUserSerializer, UserReadOnlySerializer, _visible_fields

Expand Down Expand Up @@ -193,11 +193,17 @@ 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,
)
Comment on lines +197 to +200
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The openedx-filters call site is missing the standard # .. filter_implemented_name: / # .. filter_type: annotations used elsewhere in the codebase for filter invocations, which are relied on for documentation/discovery. Add these two comment lines immediately above the run_filter call (see e.g. openedx/core/djangoapps/user_authn/views/login.py around the StudentLoginRequested.run_filter usage).

Copilot uses AI. Check for mistakes.
plugin_readonly_fields = plugin_readonly_fields 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
)
Comment on lines 195 to 208

for read_only_field in read_only_fields:
Expand Down
90 changes: 18 additions & 72 deletions openedx/core/djangoapps/user_api/accounts/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"""

import datetime
import itertools
import unicodedata
from unittest.mock import Mock, patch

Expand All @@ -18,7 +17,6 @@
from django.test.client import RequestFactory
from django.urls import reverse
from pytz import UTC
Comment thread
kiram15 marked this conversation as resolved.
from social_django.models import UserSocialAuth

from common.djangoapps.student.models import (
AccountRecovery,
Expand Down Expand Up @@ -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(), None),
)
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."""
Expand Down Expand Up @@ -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", "[email protected]"), ("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'}, None),
)
@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."""
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ openedx-events==10.5.0
# edx-name-affirmation
# event-tracking
# ora2
openedx-filters==2.1.0
openedx-filters==3.1.0
# via
# -r requirements/edx/kernel.in
# edx-enterprise
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1379,7 +1379,7 @@ openedx-events==10.5.0
# edx-name-affirmation
# event-tracking
# ora2
openedx-filters==2.1.0
openedx-filters==3.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ openedx-events==10.5.0
# edx-name-affirmation
# event-tracking
# ora2
openedx-filters==2.1.0
openedx-filters==3.1.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1049,7 +1049,7 @@ openedx-events==10.5.0
# edx-name-affirmation
# event-tracking
# ora2
openedx-filters==2.1.0
openedx-filters==3.1.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
Expand Down
Loading