Skip to content

Commit 835cf80

Browse files
committed
Merge branch 'master' into html-extracted
2 parents 22ff81c + e3e9dd8 commit 835cf80

File tree

27 files changed

+559
-347
lines changed

27 files changed

+559
-347
lines changed

cms/djangoapps/contentstore/views/tests/test_block.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
from xblock.runtime import DictKeyValueStore, KvsFieldData
3434
from xblock.test.tools import TestRuntime
3535
from xblock.validation import ValidationMessage
36-
from xmodule.capa_block import ProblemBlock
3736
from xmodule.course_block import DEFAULT_START_DATE
3837
from xmodule.modulestore import ModuleStoreEnum
3938
from xmodule.modulestore.django import modulestore
@@ -619,7 +618,9 @@ def test_create_nicely(self):
619618
prob_usage_key = self.response_usage_key(resp)
620619
problem = self.get_item_from_modulestore(prob_usage_key)
621620
# check against the template
622-
template = ProblemBlock.get_template(template_id)
621+
course = CourseFactory.create()
622+
problem_block = BlockFactory.create(category="problem", parent_location=course.location)
623+
template = problem_block.get_template(template_id)
623624
self.assertEqual(problem.data, template["data"])
624625
self.assertEqual(problem.display_name, template["metadata"]["display_name"])
625626
self.assertEqual(problem.markdown, template["metadata"]["markdown"])

common/djangoapps/student/models/course_enrollment.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,10 +364,8 @@ def __str__(self):
364364
"[CourseEnrollment] {}: {} ({}); active: ({})"
365365
).format(self.user, self.course_id, self.created, self.is_active)
366366

367-
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
368-
super().save(
369-
force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields
370-
)
367+
def save(self, *args, **kwargs):
368+
super().save(*args, **kwargs)
371369

372370
# Delete the cached status hash, forcing the value to be recalculated the next time it is needed.
373371
cache.delete(self.enrollment_status_hash_cache_key(self.user))

common/djangoapps/third_party_auth/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ class ThirdPartyAuthConfig(AppConfig): # lint-amnesty, pylint: disable=missing-
99
verbose_name = "Third-party authentication"
1010

1111
def ready(self):
12+
# Import signal handlers to register them
13+
from .signals import handlers # noqa: F401 pylint: disable=unused-import
14+
1215
# To override the settings before loading social_django.
1316
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH', False):
1417
self._enable_third_party_auth()

common/djangoapps/third_party_auth/management/commands/saml.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.core.management.base import BaseCommand, CommandError
99

1010
from common.djangoapps.third_party_auth.tasks import fetch_saml_metadata
11+
from common.djangoapps.third_party_auth.models import SAMLProviderConfig, SAMLConfiguration
1112

1213

1314
class Command(BaseCommand):
@@ -16,13 +17,41 @@ class Command(BaseCommand):
1617

1718
def add_arguments(self, parser):
1819
parser.add_argument('--pull', action='store_true', help="Pull updated metadata from external IDPs")
20+
parser.add_argument(
21+
'--fix-references',
22+
action='store_true',
23+
help="Fix SAMLProviderConfig references to use current SAMLConfiguration versions"
24+
)
25+
parser.add_argument(
26+
'--site-id',
27+
type=int,
28+
help='Only fix configurations for a specific site ID (to be used with --fix-references)'
29+
)
30+
parser.add_argument(
31+
'--dry-run',
32+
action='store_true',
33+
help='Show what would be changed, but do not make any changes.'
34+
)
1935

2036
def handle(self, *args, **options):
2137
should_pull_saml_metadata = options.get('pull', False)
38+
should_fix_references = options.get('fix_references', False)
39+
dry_run = options.get('dry_run', False)
2240

23-
if not should_pull_saml_metadata:
24-
raise CommandError("Command can only be used with '--pull' option.")
41+
if not should_pull_saml_metadata and not should_fix_references:
42+
raise CommandError("Command must be used with '--pull' or '--fix-references' option.")
2543

44+
if should_pull_saml_metadata:
45+
self._handle_pull_metadata()
46+
47+
if should_fix_references:
48+
self._handle_fix_references(options, dry_run=dry_run)
49+
50+
def _handle_pull_metadata(self):
51+
"""
52+
Handle the --pull option to fetch and update SAML metadata from external providers.
53+
This sets up logging and calls the fetch_saml_metadata task.
54+
"""
2655
log_handler = logging.StreamHandler(self.stdout)
2756
log_handler.setLevel(logging.DEBUG)
2857
log = logging.getLogger('common.djangoapps.third_party_auth.tasks')
@@ -46,3 +75,46 @@ def handle(self, *args, **options):
4675
failures="\n\n".join(failure_messages)
4776
)
4877
)
78+
79+
def _handle_fix_references(self, options, dry_run=False):
80+
"""Handle the --fix-references option for fixing outdated SAML configuration references."""
81+
site_id = options.get('site_id')
82+
updated_count = 0
83+
error_count = 0
84+
85+
# Filter by site if specified
86+
provider_configs = SAMLProviderConfig.objects.current_set()
87+
if site_id:
88+
provider_configs = provider_configs.filter(site_id=site_id)
89+
90+
for provider_config in provider_configs:
91+
if provider_config.saml_configuration:
92+
try:
93+
current_config = SAMLConfiguration.current(
94+
provider_config.site_id,
95+
provider_config.saml_configuration.slug
96+
)
97+
98+
if current_config and current_config.id != provider_config.saml_configuration_id:
99+
self.stdout.write(
100+
f"Provider '{provider_config.slug}' (site {provider_config.site_id}) "
101+
f"has outdated config (ID: {provider_config.saml_configuration_id} -> {current_config.id})"
102+
)
103+
104+
if not dry_run:
105+
provider_config.saml_configuration = current_config
106+
provider_config.save()
107+
updated_count += 1
108+
109+
except Exception as e: # pylint: disable=broad-except
110+
self.stderr.write(
111+
f"Error processing provider '{provider_config.slug}': {e}"
112+
)
113+
error_count += 1
114+
115+
style = self.style.SUCCESS
116+
if dry_run:
117+
msg = f"[DRY RUN] Would update {updated_count} provider configurations. {error_count} errors encountered."
118+
else:
119+
msg = f"Updated {updated_count} provider configurations. {error_count} errors encountered."
120+
self.stdout.write(style(msg))

common/djangoapps/third_party_auth/management/commands/tests/test_saml.py

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from io import StringIO
99

1010
from unittest import mock
11+
from ddt import ddt, data, unpack
12+
from django.contrib.sites.models import Site
1113
from django.core.management import call_command
1214
from django.core.management.base import CommandError
1315
from requests import exceptions
@@ -16,6 +18,8 @@
1618
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
1719
from common.djangoapps.third_party_auth.tests.factories import SAMLConfigurationFactory, SAMLProviderConfigFactory
1820

21+
from common.djangoapps.third_party_auth.models import SAMLProviderConfig
22+
1923

2024
def mock_get(status_code=200):
2125
"""
@@ -45,6 +49,7 @@ def _(url=None, *args, **kwargs): # lint-amnesty, pylint: disable=keyword-arg-b
4549

4650

4751
@skip_unless_lms
52+
@ddt
4853
class TestSAMLCommand(CacheIsolationTestCase):
4954
"""
5055
Test django management command for fetching saml metadata.
@@ -58,12 +63,17 @@ def setUp(self):
5863
super().setUp()
5964

6065
self.stdout = StringIO()
66+
self.site = Site.objects.get_current()
6167

6268
# We are creating SAMLConfiguration instance here so that there is always at-least one
6369
# disabled saml configuration instance, this is done to verify that disabled configurations are
6470
# not processed.
65-
SAMLConfigurationFactory.create(enabled=False, site__domain='testserver.fake', site__name='testserver.fake')
66-
SAMLProviderConfigFactory.create(
71+
self.saml_config = SAMLConfigurationFactory.create(
72+
enabled=False,
73+
site__domain='testserver.fake',
74+
site__name='testserver.fake'
75+
)
76+
self.provider_config = SAMLProviderConfigFactory.create(
6777
site__domain='testserver.fake',
6878
site__name='testserver.fake',
6979
slug='test-shib',
@@ -72,6 +82,44 @@ def setUp(self):
7282
metadata_source='https://www.testshib.org/metadata/testshib-providers.xml',
7383
)
7484

85+
def _setup_test_configs_for_fix_references(self):
86+
"""
87+
Helper method to create SAML configurations for fix-references tests.
88+
89+
Returns tuple of (old_config, new_config, provider_config)
90+
91+
Using a separate method keeps test data isolated. Including these configs in
92+
setUp would create 3 provider configs for all tests, breaking tests that expect
93+
specific provider counts or try to access non-existent test XML files.
94+
"""
95+
# Create a SAML config that will be outdated after the new config is created
96+
old_config = SAMLConfigurationFactory.create(
97+
enabled=False,
98+
site=self.site,
99+
slug='test-config',
100+
entity_id='https://old.example.com'
101+
)
102+
103+
# Create newer config with same slug
104+
new_config = SAMLConfigurationFactory.create(
105+
enabled=True,
106+
site=self.site,
107+
slug='test-config',
108+
entity_id='https://updated.example.com'
109+
)
110+
111+
# Create a provider config that references the old config for fix-references tests
112+
test_provider_config = SAMLProviderConfigFactory.create(
113+
site=self.site,
114+
slug='test-provider',
115+
name='Test Provider',
116+
entity_id='https://test.provider/idp/shibboleth',
117+
metadata_source='https://test.provider/metadata.xml',
118+
saml_configuration=old_config
119+
)
120+
121+
return old_config, new_config, test_provider_config
122+
75123
def __create_saml_configurations__(self, saml_config=None, saml_provider_config=None):
76124
"""
77125
Helper method to create SAMLConfiguration and AMLProviderConfig.
@@ -101,11 +149,11 @@ def test_raises_command_error_for_invalid_arguments(self):
101149
This test would fail with an error if ValueError is raised.
102150
"""
103151
# Call `saml` command without any argument so that it raises a CommandError
104-
with self.assertRaisesMessage(CommandError, "Command can only be used with '--pull' option."):
152+
with self.assertRaisesMessage(CommandError, "Command must be used with '--pull' or '--fix-references' option."):
105153
call_command("saml")
106154

107155
# Call `saml` command without any argument so that it raises a CommandError
108-
with self.assertRaisesMessage(CommandError, "Command can only be used with '--pull' option."):
156+
with self.assertRaisesMessage(CommandError, "Command must be used with '--pull' or '--fix-references' option."):
109157
call_command("saml", pull=False)
110158

111159
def test_no_saml_configuration(self):
@@ -285,3 +333,60 @@ def test_xml_parse_exceptions(self, mocked_get):
285333
with self.assertRaisesRegex(CommandError, "XMLSyntaxError:"):
286334
call_command("saml", pull=True, stdout=self.stdout)
287335
assert expected in self.stdout.getvalue()
336+
337+
@data(
338+
(True, '[DRY RUN]', 'should not update provider configs'),
339+
(False, '', 'should create new provider config for new version')
340+
)
341+
@unpack
342+
def test_fix_references(self, dry_run, expected_output_marker, test_description):
343+
"""
344+
Test the --fix-references command with and without --dry-run option.
345+
346+
Args:
347+
dry_run (bool): Whether to run with --dry-run flag
348+
expected_output_marker (str): Expected marker in output
349+
test_description (str): Description of what the test should do
350+
"""
351+
old_config, new_config, test_provider_config = self._setup_test_configs_for_fix_references()
352+
new_config_id = new_config.id
353+
original_config_id = old_config.id
354+
355+
out = StringIO()
356+
if dry_run:
357+
call_command('saml', '--fix-references', '--dry-run', stdout=out)
358+
else:
359+
call_command('saml', '--fix-references', stdout=out)
360+
361+
output = out.getvalue()
362+
363+
self.assertIn('test-provider', output)
364+
if expected_output_marker:
365+
self.assertIn(expected_output_marker, output)
366+
367+
test_provider_config.refresh_from_db()
368+
369+
if dry_run:
370+
# For dry run, ensure the provider config was NOT updated
371+
self.assertEqual(
372+
test_provider_config.saml_configuration_id,
373+
original_config_id,
374+
"Provider config should not be updated in dry run mode"
375+
)
376+
else:
377+
# For actual run, check that a new provider config was created
378+
new_provider = SAMLProviderConfig.objects.filter(
379+
site=self.site,
380+
slug='test-provider',
381+
saml_configuration_id=new_config_id
382+
).exclude(id=test_provider_config.id).first()
383+
384+
self.assertIsNotNone(new_provider, "New provider config should be created")
385+
self.assertEqual(new_provider.saml_configuration_id, new_config_id)
386+
387+
# Original provider config should still reference the old config
388+
self.assertEqual(
389+
test_provider_config.saml_configuration_id,
390+
original_config_id,
391+
"Original provider config should still reference old config"
392+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Signal handlers for third_party_auth app
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Signal handlers for third_party_auth app.
3+
"""
4+
5+
from django.db.models.signals import post_save
6+
from django.dispatch import receiver
7+
from edx_django_utils.monitoring import set_custom_attribute
8+
9+
from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig
10+
from common.djangoapps.third_party_auth.toggles import ENABLE_SAML_CONFIG_SIGNAL_HANDLERS
11+
12+
13+
@receiver(post_save, sender=SAMLConfiguration)
14+
def update_saml_provider_configs_on_configuration_change(sender, instance, created, **kwargs):
15+
"""
16+
Signal handler to create a new SAMLProviderConfig when SAMLConfiguration is updated.
17+
18+
When a SAMLConfiguration is updated and a new version is created, this handler
19+
generates a corresponding SAMLProviderConfig that references the latest
20+
configuration version, ensuring all providers remain aligned with the most
21+
current settings.
22+
"""
23+
# .. custom_attribute_name: saml_config_signal.enabled
24+
# .. custom_attribute_description: Tracks whether the SAML config signal handler is enabled.
25+
set_custom_attribute('saml_config_signal.enabled', ENABLE_SAML_CONFIG_SIGNAL_HANDLERS.is_enabled())
26+
27+
# .. custom_attribute_name: saml_config_signal.new_config_id
28+
# .. custom_attribute_description: Records the ID of the new SAML configuration instance.
29+
set_custom_attribute('saml_config_signal.new_config_id', instance.id)
30+
31+
# .. custom_attribute_name: saml_config_signal.slug
32+
# .. custom_attribute_description: Records the slug of the SAML configuration instance.
33+
set_custom_attribute('saml_config_signal.slug', instance.slug)
34+
35+
if ENABLE_SAML_CONFIG_SIGNAL_HANDLERS.is_enabled():
36+
try:
37+
# Find all existing SAMLProviderConfig instances (current_set) that should be
38+
# pointing to this slug but are pointing to an older version
39+
existing_providers = SAMLProviderConfig.objects.current_set().filter(
40+
site_id=instance.site_id,
41+
saml_configuration__slug=instance.slug
42+
).exclude(saml_configuration_id=instance.id)
43+
44+
updated_count = 0
45+
for provider_config in existing_providers:
46+
provider_config.saml_configuration = instance
47+
provider_config.save()
48+
updated_count += 1
49+
50+
# .. custom_attribute_name: saml_config_signal.updated_count
51+
# .. custom_attribute_description: The number of SAMLProviderConfig records updated to point to the new configuration.
52+
set_custom_attribute('saml_config_signal.updated_count', updated_count)
53+
54+
except Exception as e: # pylint: disable=broad-except
55+
# .. custom_attribute_name: saml_config_signal.error_message
56+
# .. custom_attribute_description: Records any error message that occurs during SAML provider config updates.
57+
set_custom_attribute('saml_config_signal.error_message', str(e))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# This file marks the directory as a Python package.

0 commit comments

Comments
 (0)