From 5eb3e7c475592aa037735fde99114970d99190ae Mon Sep 17 00:00:00 2001 From: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:45:18 +0500 Subject: [PATCH] feat: let operators enable xblocks in every course's advanced list Adds an advanced_by_default flag to XBlockStudioConfiguration, so operators can offer an XBlock in the Advanced component list of every course from Django admin, instead of course teams adding it to each course's Advanced Module List and operators patching DEFAULT_ADVANCED_MODULES in a fork. get_component_templates now unions these xblocks with the course's own advanced_modules and the hardcoded DEFAULT_ADVANCED_MODULES. Downstream filtering is unchanged, so XBlockConfiguration and Studio support levels still apply. The flag defaults to False, so behaviour is unchanged until an operator sets it. This is the operator-configurable follow-up left open in #36532, where DEFAULT_ADVANCED_MODULES was moved out of settings and into code. --- .../contentstore/views/component.py | 11 +++- .../contentstore/views/tests/test_block.py | 63 +++++++++++++++++++ common/djangoapps/xblock_django/admin.py | 9 +++ common/djangoapps/xblock_django/api.py | 16 +++++ ...studioconfiguration_advanced_by_default.py | 16 +++++ common/djangoapps/xblock_django/models.py | 12 +++- .../xblock_django/tests/test_api.py | 51 ++++++++++++++- 7 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 common/djangoapps/xblock_django/migrations/0005_xblockstudioconfiguration_advanced_by_default.py diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 314d18fa48d0..dfd230076144 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -26,7 +26,7 @@ from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_course_author_access -from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks +from common.djangoapps.xblock_django.api import authorable_xblocks, default_advanced_xblocks, disabled_xblocks from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag from openedx.core.djangoapps.content_tagging.api import get_object_tags from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration @@ -73,6 +73,9 @@ "edit-title-button", "edit-upstream-alert", ] +# Advanced modules which are offered in every course, in addition to the ones each course lists in its own +# Advanced Module List. Operators can add to this list without forking the platform by marking XBlocks as +# advanced by default in XBlockStudioConfiguration (see `default_advanced_xblocks`). DEFAULT_ADVANCED_MODULES = [ 'google-calendar', 'google-document', @@ -444,8 +447,10 @@ def create_support_legend_dict(): # Check if there are any advanced modules specified in the course policy. # These modules should be specified as a list of strings, where the strings # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be - # enabled for the course. - course_advanced_keys = list(dict.fromkeys(courselike.advanced_modules + DEFAULT_ADVANCED_MODULES)) + # enabled for the course. They are combined with the modules which are enabled for every course: the + # platform-wide DEFAULT_ADVANCED_MODULES, plus any which this operator has marked as advanced by default. + default_advanced_keys = DEFAULT_ADVANCED_MODULES + [block.name for block in default_advanced_xblocks()] + course_advanced_keys = list(dict.fromkeys(courselike.advanced_modules + default_advanced_keys)) advanced_component_templates = { "type": "advanced", "templates": [], diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 62798c7e8701..d943dabac326 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -2945,6 +2945,69 @@ def test_advanced_components(self): self.templates = get_component_templates(self.course) self.assertTrue((not any(item.get("category") == "done" for item in self.get_templates_of_type("advanced")))) # noqa: PT009, UP034 # pylint: disable=line-too-long + def test_advanced_by_default_components(self): + """ + Test that an xblock which an operator has marked as advanced by default is offered to a course + which does not list it in its own Advanced Module List. + """ + advanced_categories = [ + template.get("category") for template in self.get_templates_of_type("advanced") + ] + self.assertNotIn("done", advanced_categories) # noqa: PT009 + + XBlockStudioConfiguration.objects.create( + name="done", enabled=True, support_level="fs", advanced_by_default=True + ) + self.templates = get_component_templates(self.course) + advanced_templates = self.get_templates_of_type("advanced") + self.assertEqual(len(advanced_templates), len(DEFAULT_ADVANCED_MODULES) + 1) # noqa: PT009 + done_template = self.get_template(advanced_templates, "Completion") + self.assertEqual(done_template.get("category"), "done") # noqa: PT009 + self.assertIsNone(done_template.get("boilerplate_name", None)) # noqa: PT009 + + # Verify that the component is not added twice if the course lists it as well. + self.course.advanced_modules.append("done") + self.templates = get_component_templates(self.course) + self.assertEqual( # noqa: PT009 + len(self.get_templates_of_type("advanced")), len(DEFAULT_ADVANCED_MODULES) + 1 + ) + + # Now fully disable done through XBlockConfiguration. + XBlockConfiguration.objects.create(name="done", enabled=False) + self.templates = get_component_templates(self.course) + self.assertTrue((not any(item.get("category") == "done" for item in self.get_templates_of_type("advanced")))) # noqa: PT009, UP034 # pylint: disable=line-too-long + + def test_advanced_by_default_components_support_levels(self): + """ + Test that an xblock which is advanced by default still honors Studio support levels, so that + opting an unsupported xblock into every course requires course author opt-in as usual. + """ + XBlockStudioConfiguration.objects.create( + name="done", enabled=True, support_level="us", advanced_by_default=True + ) + XBlockStudioConfigurationFlag.objects.create(enabled=True) + + self.templates = get_component_templates(self.course) + self.assertTrue((not any(item.get("category") == "done" for item in self.get_templates_of_type("advanced")))) # noqa: PT009, UP034 # pylint: disable=line-too-long + + self.course.allow_unsupported_xblocks = True + self.templates = get_component_templates(self.course) + done_template = self.get_template(self.get_templates_of_type("advanced"), "Completion") + self.assertEqual(done_template.get("category"), "done") # noqa: PT009 + self.assertEqual(done_template.get("support_level"), "us") # noqa: PT009 + + def test_advanced_by_default_components_not_offered_to_libraries(self): + """ + Test that xblocks which are advanced by default are not offered to libraries, which do not + support advanced components at all. + """ + XBlockStudioConfiguration.objects.create( + name="done", enabled=True, support_level="fs", advanced_by_default=True + ) + library = LibraryFactory.create() + self.templates = get_component_templates(library, library=True) + self.assertIsNone(self.get_templates_of_type("advanced")) # noqa: PT009 + def test_deprecated_no_advance_component_button(self): """ Test that there will be no `Advanced` button on unit page if xblocks have disabled diff --git a/common/djangoapps/xblock_django/admin.py b/common/djangoapps/xblock_django/admin.py index a36553dc0645..d9f9149f31eb 100644 --- a/common/djangoapps/xblock_django/admin.py +++ b/common/djangoapps/xblock_django/admin.py @@ -58,6 +58,15 @@ class XBlockStudioConfigurationAdmin(KeyedConfigurationModelAdmin): ), 'fields': ('support_level',) }), + ('Enable in All Courses', { + 'description': _( + "XBlocks that are advanced by default are offered in the Advanced component list of every course, " + "so that course teams do not have to add them to each course's Advanced Module List. The XBlock " + "must also be enabled above and in XBlockConfiguration, and, if XBlockStudioConfigurationFlag is " + "enabled, have a support level which allows course authors to create it." + ), + 'fields': ('advanced_by_default',) + }), ) diff --git a/common/djangoapps/xblock_django/api.py b/common/djangoapps/xblock_django/api.py index 1470ae3a7cc9..b8e2a1d0dea9 100644 --- a/common/djangoapps/xblock_django/api.py +++ b/common/djangoapps/xblock_django/api.py @@ -7,6 +7,7 @@ from openedx.core.lib.cache_utils import CacheInvalidationManager cacher = CacheInvalidationManager(model=XBlockConfiguration) +studio_config_cacher = CacheInvalidationManager(model=XBlockStudioConfiguration) @cacher @@ -27,6 +28,21 @@ def disabled_xblocks(): return XBlockConfiguration.objects.current_set().filter(enabled=False) +@studio_config_cacher +def default_advanced_xblocks(): + """ + Return the QuerySet of XBlock types which operators have marked as advanced by default, meaning that + Studio offers them in the Advanced component list of every course, without course teams having to add + them to the course's Advanced Module List. + + Note that this method is independent of `XBlockStudioConfigurationFlag`: the flag governs whether support + levels are enforced, not whether an operator has opted an XBlock into every course. It does not take into + account fully disabled xblocks (as returned by `disabled_xblocks`) or deprecated xblocks (as returned by + `deprecated_xblocks`); callers are expected to filter those out, as `get_component_templates` does. + """ + return XBlockStudioConfiguration.objects.current_set().filter(enabled=True, advanced_by_default=True) + + def authorable_xblocks(allow_unsupported=False, name=None): """ This method returns the QuerySet of XBlocks that can be created in Studio (by default, only fully supported diff --git a/common/djangoapps/xblock_django/migrations/0005_xblockstudioconfiguration_advanced_by_default.py b/common/djangoapps/xblock_django/migrations/0005_xblockstudioconfiguration_advanced_by_default.py new file mode 100644 index 000000000000..cddcd7e01196 --- /dev/null +++ b/common/djangoapps/xblock_django/migrations/0005_xblockstudioconfiguration_advanced_by_default.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('xblock_django', '0004_delete_xblock_disable_config'), + ] + + operations = [ + migrations.AddField( + model_name='xblockstudioconfiguration', + name='advanced_by_default', + field=models.BooleanField(default=False, help_text="Offer this XBlock in the Advanced component list of every course, without course teams having to add it to the course's Advanced Module List. Has no effect on XBlocks that are already offered as basic components."), + ), + ] diff --git a/common/djangoapps/xblock_django/models.py b/common/djangoapps/xblock_django/models.py index cb76d713641a..465dbf4bde73 100644 --- a/common/djangoapps/xblock_django/models.py +++ b/common/djangoapps/xblock_django/models.py @@ -71,11 +71,19 @@ class XBlockStudioConfiguration(ConfigurationModel): name = models.CharField(max_length=255, null=False, db_index=True) template = models.CharField(max_length=255, blank=True, default='') support_level = models.CharField(max_length=2, choices=SUPPORT_CHOICES, default=UNSUPPORTED) + advanced_by_default = models.BooleanField( + default=False, + help_text=_( + "Offer this XBlock in the Advanced component list of every course, without course teams having to " + "add it to the course's Advanced Module List. Has no effect on XBlocks that are already offered as " + "basic components." + ) + ) class Meta: app_label = "xblock_django" def __str__(self): return ( # noqa: UP032 - "XBlockStudioConfiguration(name={}, template={}, enabled={}, support_level={})" - ).format(self.name, self.template, self.enabled, self.support_level) + "XBlockStudioConfiguration(name={}, template={}, enabled={}, support_level={}, advanced_by_default={})" + ).format(self.name, self.template, self.enabled, self.support_level, self.advanced_by_default) diff --git a/common/djangoapps/xblock_django/tests/test_api.py b/common/djangoapps/xblock_django/tests/test_api.py index 14d39a994710..c13939dcba9a 100644 --- a/common/djangoapps/xblock_django/tests/test_api.py +++ b/common/djangoapps/xblock_django/tests/test_api.py @@ -1,7 +1,12 @@ """ Tests related to XBlock support API. """ -from common.djangoapps.xblock_django.api import authorable_xblocks, deprecated_xblocks, disabled_xblocks +from common.djangoapps.xblock_django.api import ( + authorable_xblocks, + default_advanced_xblocks, + deprecated_xblocks, + disabled_xblocks, +) from common.djangoapps.xblock_django.models import ( # pylint: disable=line-too-long XBlockConfiguration, XBlockStudioConfiguration, @@ -65,6 +70,50 @@ def test_disabled_blocks(self): disabled_xblock_names = [block.name for block in disabled_xblocks()] self.assertCountEqual(["survey", "poll"], disabled_xblock_names) # noqa: PT009 + def test_default_advanced_blocks(self): + """ Tests the default_advanced_xblocks method """ + + # None of the xblocks configured in setUp are advanced by default. + assert [] == [block.name for block in default_advanced_xblocks()] + + XBlockStudioConfiguration( + name="done", template="", enabled=True, support_level=XBlockStudioConfiguration.FULL_SUPPORT, + advanced_by_default=True + ).save() + # Note that support level is not taken into account: it is enforced by Studio (and only when + # XBlockStudioConfigurationFlag is enabled), not by this method. + XBlockStudioConfiguration( + name="split_module", template="", enabled=True, support_level=XBlockStudioConfiguration.UNSUPPORTED, + advanced_by_default=True + ).save() + default_advanced_xblock_names = [block.name for block in default_advanced_xblocks()] + self.assertCountEqual(["done", "split_module"], default_advanced_xblock_names) # noqa: PT009 + + # An xblock which is disabled in XBlockStudioConfiguration cannot be created in Studio at all, + # so it is not advanced by default either. + XBlockStudioConfiguration( + name="done", template="", enabled=False, support_level=XBlockStudioConfiguration.FULL_SUPPORT, + advanced_by_default=True + ).save() + default_advanced_xblock_names = [block.name for block in default_advanced_xblocks()] + self.assertCountEqual(["split_module"], default_advanced_xblock_names) # noqa: PT009 + + def test_default_advanced_blocks_ignores_studio_configuration_flag(self): + """ + Tests that default_advanced_xblocks is independent of XBlockStudioConfigurationFlag, which governs + whether support levels are enforced rather than whether an operator has opted an xblock into all courses. + """ + XBlockStudioConfiguration( + name="done", template="", enabled=True, support_level=XBlockStudioConfiguration.FULL_SUPPORT, + advanced_by_default=True + ).save() + + assert not XBlockStudioConfigurationFlag.is_enabled() + assert ["done"] == [block.name for block in default_advanced_xblocks()] + + XBlockStudioConfigurationFlag(enabled=True).save() + assert ["done"] == [block.name for block in default_advanced_xblocks()] + def test_authorable_blocks_empty_model(self): """ Tests authorable_xblocks returns an empty list if XBlockStudioConfiguration table is empty, regardless