Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from datetime import datetime

from bson.objectid import ObjectId
from django.test import TestCase
from django.db import connection
from django.test import TestCase, skipUnlessDBFeature
from opaque_keys.edx.keys import CourseKey

from common.djangoapps.split_modulestore_django.models import SplitModulestoreCourseIndex
Expand All @@ -12,6 +13,7 @@
class SplitModulestoreCourseIndexTest(TestCase):
""" Unit tests for SplitModulestoreCourseIndex """

@skipUnlessDBFeature('supports_collation_on_charfield')
def test_course_id_case_sensitive(self):
"""
Make sure the course_id column is case sensitive.
Expand All @@ -23,6 +25,8 @@ def test_course_id_case_sensitive(self):
sensitive too. The system still tries to prevent creation of courses that differ only by course (that hasn't
changed), but now the MySQL version won't break if that has somehow happened.
"""
if connection.vendor != 'mysql':
self.skipTest("Requires MySQL utf8_bin case-sensitive collation")
course_index_common = {
"course": "TL101",
"run": "2015",
Expand Down
12 changes: 7 additions & 5 deletions common/djangoapps/third_party_auth/saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,21 +287,23 @@ def get_user_details(self, attributes):
})
return details

def get_attr(self, attributes, conf_key, default_attribute):
def get_attr(self, attributes, conf_key, default_attributes, *, validate_defaults=False):
"""
Internal helper method.
Get the attribute 'default_attribute' out of the attributes,
Get the attribute 'default_attributes' out of the attributes,
unless self.conf[conf_key] overrides the default by specifying
another attribute to use.
"""
key = self.conf.get(conf_key, default_attribute)
if key in attributes:
key = self.conf.get(conf_key)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The PR upgraded social-auth-core 4.7.0 → 4.8.7, which changed SAMLIdentityProvider.get_attr from (self, attributes, conf_key, default_attribute) (4 params) to (self, attributes, conf_key, default_attributes: tuple, *, validate_defaults=False) (5 params).

if key is None:
key = next((attr for attr in default_attributes if attr in attributes), None)
if key is not None and key in attributes:
try:
return attributes[key][0]
except IndexError:
log.warning('[THIRD_PARTY_AUTH] SAML attribute value not found. '
'SamlAttribute: {attribute}'.format(attribute=key))
return self.conf['attr_defaults'].get(conf_key) or None
return self.conf.get('attr_defaults', {}).get(conf_key) or None
Comment thread
kiram15 marked this conversation as resolved.

@property
def saml_sp_configuration(self):
Expand Down
2 changes: 1 addition & 1 deletion common/djangoapps/third_party_auth/tests/test_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def test_get_saml_idp_class_with_fake_identifier(self, log_mock):

def test_get_user_details(self):
""" test get_attr and get_user_details of EdXSAMLIdentityProvider"""
edx_saml_identity_provider = EdXSAMLIdentityProvider('demo', **mock_conf)
edx_saml_identity_provider = EdXSAMLIdentityProvider(mock.Mock(), 'demo', **mock_conf)
Copy link
Copy Markdown
Author

@kiram15 kiram15 May 4, 2026

Choose a reason for hiding this comment

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

Same social-auth-core 4.8.7 upgrade changed SAMLIdentityProvider.init from (self, entity_id, **kwargs) to (self, backend: BaseAuth, name: str, **kwargs).

assert edx_saml_identity_provider.get_user_details(mock_attributes) == expected_user_details


Expand Down
12 changes: 12 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3705,6 +3705,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: {"org.openedx.learning.account.settings.read_only_fields.requested.v1": {"fail_silently": true, "pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"]}}
# .. 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
2 changes: 2 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,5 @@ ignore_missing_imports = True
ignore_missing_imports = True
[mypy-xblock.*]
ignore_missing_imports = True
[mypy-requests.*]
ignore_missing_imports = True
Comment on lines +73 to +74
12 changes: 9 additions & 3 deletions openedx/core/djangoapps/user_api/accounts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
email_exists_or_retired,
username_exists_or_retired
)
from common.djangoapps.third_party_auth.utils import get_saml_provider_for_user
from common.djangoapps.util.model_utils import emit_settings_changed_event
from common.djangoapps.util.password_policy_validators import validate_password
from lms.djangoapps.certificates.api import get_certificates_for_user
Expand All @@ -38,9 +39,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 common.djangoapps.third_party_auth.utils import get_saml_provider_for_user
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 @@ -194,11 +194,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
89 changes: 18 additions & 71 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 Down Expand Up @@ -104,10 +103,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 +249,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
14 changes: 10 additions & 4 deletions requirements/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@ cryptography<46.0.0
pact-python<3.0.0

# Date 2026-02-05
# sphinx-autoapi==3.6.1 is incompatible with astroid==4.x under python 3.11. Since we (2U) currently
# deploy edxapp to edx.org using python 3.11, we must pin sphinx-autoapi to avoid dependency
# conflicts. Remove this constraint once we migrate to python 3.12.
sphinx-autoapi<3.6.1
# Pin astroid to 4.0.4 for compatibility with sphinx-autoapi and pylint under Python 3.11
# sphinx-autoapi<3.6.1 is incompatible with astroid>=4.1.2, so we pin astroid to 4.0.x series
# to ensure all dependencies can resolve properly.
# Remove this constraint once we migrate to Python 3.12.
Comment on lines +141 to +144
astroid==4.0.4

# Date: 2026-05-04
# lti-consumer-xblock 11.x introduced breaking changes that are not yet
# compatible with this branch. Pin to 9.14.5 until we move officially to python 3.12.
lti-consumer-xblock==9.14.5
32 changes: 16 additions & 16 deletions requirements/edx-sandbox/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ cffi==2.0.0
# via cryptography
chem==2.0.0
# via -r requirements/edx-sandbox/base.in
click==8.3.0
click==8.3.3
# via nltk
codejail-includes==2.0.0
# via -r requirements/edx-sandbox/base.in
Expand All @@ -20,31 +20,31 @@ cryptography==45.0.7
# -r requirements/edx-sandbox/base.in
cycler==0.12.1
# via matplotlib
fonttools==4.60.1
fonttools==4.62.1
# via matplotlib
joblib==1.5.2
joblib==1.5.3
# via nltk
kiwisolver==1.4.9
kiwisolver==1.5.0
# via matplotlib
lxml[html-clean]==5.3.2
# via
# -c requirements/constraints.txt
# -r requirements/edx-sandbox/base.in
# lxml-html-clean
# openedx-calc
lxml-html-clean==0.4.3
lxml-html-clean==0.4.4
# via lxml
markupsafe==3.0.3
# via
# chem
# openedx-calc
matplotlib==3.10.6
matplotlib==3.10.9
# via -r requirements/edx-sandbox/base.in
mpmath==1.3.0
# via sympy
networkx==3.5
networkx==3.6.1
# via -r requirements/edx-sandbox/base.in
nltk==3.9.2
nltk==3.9.4
# via
# -r requirements/edx-sandbox/base.in
# chem
Expand All @@ -56,15 +56,15 @@ numpy==1.26.4
# matplotlib
# openedx-calc
# scipy
openedx-calc==4.0.2
openedx-calc==4.0.3
# via -r requirements/edx-sandbox/base.in
packaging==25.0
packaging==26.2
# via matplotlib
pillow==11.3.0
pillow==12.2.0
# via matplotlib
pycparser==2.23
pycparser==3.0
# via cffi
pyparsing==3.2.5
pyparsing==3.3.2
# via
# -r requirements/edx-sandbox/base.in
# chem
Expand All @@ -74,9 +74,9 @@ python-dateutil==2.9.0.post0
# via matplotlib
random2==1.0.2
# via -r requirements/edx-sandbox/base.in
regex==2025.9.18
regex==2026.4.4
# via nltk
scipy==1.16.2
scipy==1.17.1
# via
# -r requirements/edx-sandbox/base.in
# chem
Expand All @@ -86,5 +86,5 @@ sympy==1.14.0
# via
# -r requirements/edx-sandbox/base.in
# openedx-calc
tqdm==4.67.1
tqdm==4.67.3
# via nltk
4 changes: 2 additions & 2 deletions requirements/edx/assets.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
#
# make upgrade
#
click==8.3.0
click==8.3.3
# via -r requirements/edx/assets.in
libsass==0.10.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/assets.in
nodeenv==1.9.1
nodeenv==1.10.0
# via -r requirements/edx/assets.in
six==1.17.0
# via libsass
Loading
Loading