From 173b87644e642e4d5e2ec791cb58b596c1523063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 25 Sep 2026 13:29:34 -0300 Subject: [PATCH] fix(content_staging): fall back to block type when XBlock plugin is missing The clipboard holds a *copy* of a block, so its XBlock plugin may no longer be installed (e.g. a custom XBlock was removed from the platform). StagedContentSerializer.get_block_type_display() called xblock_type_display_name(), which raises PluginMissingError in that case, so reading the clipboard raised, and because the clipboard is embedded in the course index response (initial_user_clipboard), the whole course index request 500s -- for every course, for any user who happens to have such a clipboard. The same applies to the /api/content-staging/v1/clipboard/ endpoint, and there is no API to discard the stale clipboard, so authors get stuck. Fall back to the raw block type, mirroring openedx.core.djangoapps.xblock.api.xblock_type_display_name, which already handles a missing plugin this way. --- .../djangoapps/content_staging/serializers.py | 11 ++++++- .../content_staging/tests/test_clipboard.py | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_staging/serializers.py b/openedx/core/djangoapps/content_staging/serializers.py index 45b08ee99e1b..928a5e5e587b 100644 --- a/openedx/core/djangoapps/content_staging/serializers.py +++ b/openedx/core/djangoapps/content_staging/serializers.py @@ -2,6 +2,7 @@ Serializers for the content libraries REST API """ from rest_framework import serializers +from xblock.plugin import PluginMissingError from common.djangoapps.student.auth import has_studio_read_access from xmodule.modulestore.django import modulestore @@ -36,7 +37,15 @@ def get_block_type_display(self, obj): """ Get the friendly name for this XBlock/component type """ from cms.djangoapps.contentstore.helpers import xblock_type_display_name - return xblock_type_display_name(obj.block_type) + try: + return xblock_type_display_name(obj.block_type) + except PluginMissingError: + # The clipboard holds a *copy* of a block, so the XBlock may no longer be installed (e.g. its + # plugin was removed from the platform after the content was copied). Fall back to the raw block + # type instead of raising, so that one stale clipboard cannot break unrelated requests (the + # clipboard is embedded in the course index response for every course). This mirrors + # openedx.core.djangoapps.xblock.api.xblock_type_display_name, which already does this. + return obj.block_type class UserClipboardSerializer(serializers.Serializer): diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 083125870113..d588cbed950d 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -13,6 +13,7 @@ from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_staging import api as python_api +from openedx.core.djangoapps.content_staging.models import StagedContent from xmodule.contentstore.django import contentstore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory @@ -133,6 +134,34 @@ def test_copy_video_python_get(self) -> None: assert olx_data is not None self.assertXmlEqual(olx_data, SAMPLE_VIDEO_OLX) + def test_uninstalled_xblock_in_clipboard(self) -> None: + """ + A clipboard may hold a *copy* of a block whose XBlock plugin is no longer installed. Reading the + clipboard must still work (it is embedded in the course index response for every course), falling + back to the raw block type for the display name. + """ + course_key, client = self._setup_course() + + # Copy the video to the clipboard: + video_key = course_key.make_usage_key("video", "sample_video") + response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json") + assert response.status_code == 200 + staged_content_id = response.json()["content"]["id"] + assert response.json()["content"]["block_type_display"] == "Video" + + # Pretend the plugin for this block type was uninstalled after the content was copied: + StagedContent.objects.filter(pk=staged_content_id).update(block_type="an_uninstalled_xblock") + + # The REST API should answer 200, using the raw block type as the display name: + response = client.get(CLIPBOARD_ENDPOINT) + assert response.status_code == 200 + response_data = response.json() + assert response_data["content"]["block_type"] == "an_uninstalled_xblock" + assert response_data["content"]["block_type_display"] == "an_uninstalled_xblock" + + # ...and so should the python API, which is what the course index response uses: + assert python_api.get_user_clipboard_json(self.user.id, response.wsgi_request) == response_data + def test_copy_html(self) -> None: """ Test copying an HTML XBlock from the course