diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 7c28f00501..7fc02f6020 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -72,6 +72,7 @@ from ..utils import ( from .helpers import ( create_xblock, get_parent_xblock, + import_staged_content_from_user_clipboard, is_unit, usage_key_with_run, xblock_primary_child_category, @@ -169,6 +170,8 @@ def xblock_handler(request, usage_key_string=None): :display_name: name for new xblock, optional :boilerplate: template name for populating fields, optional and only used if duplicate_source_locator is not present + :staged_content: use "clipboard" to paste from the OLX user's clipboard. (Incompatible with all other + fields except parent_locator) The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned. """ if usage_key_string: @@ -701,6 +704,19 @@ def _create_block(request): if not has_studio_write_access(request.user, usage_key.course_key): raise PermissionDenied() + if request.json.get('staged_content') == "clipboard": + # Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key': + try: + created_xblock = import_staged_content_from_user_clipboard(parent_key=usage_key, request=request) + except Exception: # pylint: disable=broad-except + log.exception("Could not paste component into location {}".format(usage_key)) + return JsonResponse({"error": _('There was a problem pasting your component.')}, status=400) + if created_xblock is None: + return JsonResponse({"error": _('Your clipboard is empty or invalid.')}, status=400) + return JsonResponse( + {'locator': str(created_xblock.location), 'courseKey': str(created_xblock.location.course_key)} + ) + category = request.json['category'] if isinstance(usage_key, LibraryUsageLocator): # Only these categories are supported at this time. diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index d58f601abc..8eee09471e 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -27,6 +27,11 @@ from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag from cms.djangoapps.contentstore.toggles import use_new_problem_editor from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration +try: + # Technically this is a django app plugin, so we should not error if it's not installed: + import openedx.core.djangoapps.content_staging.api as content_staging_api +except ImportError: + content_staging_api = None from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -185,6 +190,12 @@ def container_handler(request, usage_key_string): break index += 1 + # Get the status of the user's clipboard so they can paste components if they have something to paste + if content_staging_api: + user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) + else: + user_clipboard = {"content": None} + return render_to_response('container.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course, # Needed only for display of menus at top of page. @@ -205,7 +216,9 @@ def container_handler(request, usage_key_string): 'xblock_info': xblock_info, 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, - 'templates': CONTAINER_TEMPLATES + 'templates': CONTAINER_TEMPLATES, + # Status of the user's clipboard, exactly as would be returned from the "GET clipboard" REST API. + 'user_clipboard': user_clipboard, }) else: return HttpResponseBadRequest("Only supports HTML requests") diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index 398f0f6c63..2d82469e63 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -3,20 +3,31 @@ Helper methods for Studio views. """ import urllib +from lxml import etree from uuid import uuid4 from django.http import HttpResponse from django.utils.translation import gettext as _ from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import DefinitionLocator, LocalId from xblock.core import XBlock +from xblock.fields import ScopeIds +from xblock.runtime import IdGenerator from xmodule.modulestore.django import modulestore from xmodule.tabs import StaticTab +from cms.djangoapps.contentstore.views.preview import _load_preview_block from cms.djangoapps.models.settings.course_grading import CourseGradingModel from common.djangoapps.student import auth from common.djangoapps.student.roles import CourseCreatorRole, OrgContentCreatorRole from openedx.core.toggles import ENTRANCE_EXAMS +try: + # Technically this is a django app plugin, so we should not error if it's not installed: + import openedx.core.djangoapps.content_staging.api as content_staging_api +except ImportError: + content_staging_api = None + from ..utils import reverse_course_url, reverse_library_url, reverse_usage_url __all__ = ['event'] @@ -271,6 +282,76 @@ def create_xblock(parent_locator, user, category, display_name, boilerplate=None return created_block +class ImportIdGenerator(IdGenerator): + """ + Modulestore's IdGenerator doesn't work for importing single blocks as OLX, + so we implement our own + """ + def __init__(self, context_key): + super().__init__() + self.context_key = context_key + + def create_aside(self, definition_id, usage_id, aside_type): + """ Generate a new aside key """ + raise NotImplementedError() + + def create_usage(self, def_id) -> UsageKey: + """ Generate a new UsageKey for an XBlock """ + # Note: Split modulestore will detect this temporary ID and create a new block ID when the XBlock is saved. + return self.context_key.make_usage_key(def_id.block_type, LocalId()) + + def create_definition(self, block_type, slug=None) -> DefinitionLocator: + """ Generate a new definition_id for an XBlock """ + # Note: Split modulestore will detect this temporary ID and create a new definition ID when the XBlock is saved. + return DefinitionLocator(block_type, LocalId(block_type)) + + +def import_staged_content_from_user_clipboard(parent_key: UsageKey, request): + """ + Import a block (and any children it has) from "staged" OLX. + Does not deal with permissions or REST stuff - do that before calling this. + + Returns the newly created block on success or None if the clipboard is + empty. + """ + if not content_staging_api: + raise RuntimeError("The required content_staging app is not installed") + user_clipboard = content_staging_api.get_user_clipboard(request.user.id) + if not user_clipboard: + # Clipboard is empty or expired/error/loading + return None + block_type = user_clipboard.content.block_type + olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id) + node = etree.fromstring(olx_str) + store = modulestore() + with store.bulk_operations(parent_key.course_key): + parent_descriptor = store.get_item(parent_key) + # Some blocks like drag-and-drop only work here with the full XBlock runtime loaded: + parent_xblock = _load_preview_block(request, parent_descriptor) + runtime = parent_xblock.runtime + # Generate the new ID: + id_generator = ImportIdGenerator(parent_key.context_key) + def_id = id_generator.create_definition(block_type, user_clipboard.source_usage_key.block_id) + usage_id = id_generator.create_usage(def_id) + keys = ScopeIds(None, block_type, def_id, usage_id) + # parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either + # one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores + # 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'. + # For children of this block, obviously only id_generator is used. + xblock_class = runtime.load_block_type(block_type) + temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator) + if xblock_class.has_children and temp_xblock.children: + raise NotImplementedError("We don't yet support pasting XBlocks with children") + temp_xblock.parent = parent_key + # Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin) + temp_xblock.copied_from_block = str(user_clipboard.source_usage_key) + # Save the XBlock into modulestore. We need to save the block and its parent for this to work: + new_xblock = store.update_item(temp_xblock, request.user.id, allow_not_found=True) + parent_xblock.children.append(new_xblock.location) + store.update_item(parent_xblock, request.user.id) + return new_xblock + + def is_item_in_course_tree(item): """ Check that the item is in the course tree. diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py new file mode 100644 index 0000000000..d769239c85 --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -0,0 +1,62 @@ +""" +Test the import_staged_content_from_user_clipboard() method, which is used to +allow users to paste XBlocks that were copied using the staged_content/clipboard +APIs. +""" +from opaque_keys.edx.keys import UsageKey +from rest_framework.test import APIClient +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ToyCourseFactory + +CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/" +XBLOCK_ENDPOINT = "/xblock/" + + +class ClipboardPasteTestCase(ModuleStoreTestCase): + """ + Test Clipboard Paste functionality + """ + + def _setup_course(self): + """ Set up the "Toy Course" and an APIClient for testing clipboard functionality. """ + # Setup: + course_key = ToyCourseFactory.create().id # See xmodule/modulestore/tests/sample_courses.py + client = APIClient() + client.login(username=self.user.username, password=self.user_password) + return (course_key, client) + + def test_copy_and_paste_video(self): + """ + Test copying a video from the course, and pasting it into the same unit + """ + course_key, client = self._setup_course() + + # Check how many blocks are in the vertical currently + parent_key = course_key.make_usage_key("vertical", "vertical_test") # This is the vertical that holds the video + orig_vertical = modulestore().get_item(parent_key) + assert len(orig_vertical.children) == 4 + + # Copy the video + video_key = course_key.make_usage_key("video", "sample_video") + copy_response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json") + assert copy_response.status_code == 200 + + # Paste the video + paste_response = client.post(XBLOCK_ENDPOINT, { + "parent_locator": str(parent_key), + "staged_content": "clipboard", + }, format="json") + assert paste_response.status_code == 200 + new_block_key = UsageKey.from_string(paste_response.json()["locator"]) + + # Now there should be an extra block in the vertical: + updated_vertical = modulestore().get_item(parent_key) + assert len(updated_vertical.children) == 5 + assert updated_vertical.children[-1] == new_block_key + # And it should match the original: + orig_video = modulestore().get_item(video_key) + new_video = modulestore().get_item(new_block_key) + assert new_video.youtube_id_1_0 == orig_video.youtube_id_1_0 + # The new block should store a reference to where it was copied from + assert new_video.copied_from_block == str(video_key) diff --git a/cms/lib/xblock/authoring_mixin.py b/cms/lib/xblock/authoring_mixin.py index 5b44e1a432..bb6b742f44 100644 --- a/cms/lib/xblock/authoring_mixin.py +++ b/cms/lib/xblock/authoring_mixin.py @@ -8,6 +8,7 @@ import logging from django.conf import settings from web_fragments.fragment import Fragment from xblock.core import XBlock, XBlockMixin +from xblock.fields import String, Scope log = logging.getLogger(__name__) @@ -43,3 +44,10 @@ class AuthoringMixin(XBlockMixin): fragment.add_javascript_url(self._get_studio_resource_url('/js/xblock/authoring.js')) fragment.initialize_js('VisibilityEditorInit') return fragment + + copied_from_block = String( + # Note: used by the content_staging app. This field is not needed in the LMS. + help="ID of the block that this one was copied from, if any. Used when copying and pasting blocks in Studio.", + scope=Scope.settings, + enforce_type=True, + ) diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 15c92fac65..10da7b94b5 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -22,12 +22,14 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView 'click .move-button': 'showMoveXBlockModal', 'click .delete-button': 'deleteXBlock', 'click .show-actions-menu-button': 'showXBlockActionsMenu', - 'click .new-component-button': 'scrollToNewComponentButtons' + 'click .new-component-button': 'scrollToNewComponentButtons', + 'click .paste-component-button': 'pasteComponent', }, options: { collapsedClass: 'is-collapsed', - canEdit: true // If not specified, assume user has permission to make changes + canEdit: true, // If not specified, assume user has permission to make changes + clipboardData: { content: null }, }, view: 'container_preview', @@ -100,6 +102,7 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView } this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved); + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); }, getViewParameters: function() { @@ -147,6 +150,11 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView // Re-enable Backbone events for any updated DOM elements self.delegateEvents(); + + // Show/hide the paste button + if (!self.isLibraryPage) { + self.initializePasteButton(); + } }, block_added: options && options.block_added }); @@ -182,6 +190,59 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView } }, + initializePasteButton() { + if (this.options.canEdit) { + // We should have the user's clipboard status. + const data = this.options.clipboardData; + this.refreshPasteButton(data); + // Refresh the status when something is copied on another tab: + this.clipboardBroadcastChannel.onmessage = (event) => { this.refreshPasteButton(event.data); }; + } else { + this.$(".paste-component").hide(); + } + }, + + /** + * Given the latest information about the user's clipboard, hide or show the Paste button as appropriate. + */ + refreshPasteButton(data) { + // 'data' is the same data returned by the "get clipboard status" API endpoint + // i.e. /api/content-staging/v1/clipboard/ + if (this.options.canEdit && data.content) { + // TODO: check if this is suitable for pasting into a unit + this.$(".paste-component").show(); + } else { + this.$(".paste-component").hide(); + } + }, + + /** The user has clicked on the "Paste Component button" */ + pasteComponent(event) { + event.preventDefault(); + // Get the ID of the container (usually a unit/vertical) that we're pasting into: + const parentElement = this.findXBlockElement(event.target); + const parentLocator = parentElement.data('locator'); + // Create a placeholder XBlock while we're pasting: + const $placeholderEl = $(this.createPlaceholderElement()); + const addComponentsPanel = $(event.target).closest('.paste-component').prev(); + const listPanel = addComponentsPanel.prev(); + const scrollOffset = ViewUtils.getScrollOffset(addComponentsPanel); + const placeholderElement = $placeholderEl.appendTo(listPanel); + + // Start showing a "Pasting" notification: + ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => { + return $.postJSON(this.getURLRoot() + '/', { + parent_locator: parentLocator, + staged_content: "clipboard", + }).then((data) => { + this.onNewXBlock(placeholderElement, scrollOffset, false, data); + }).fail(() => { + // Remove the placeholder if the paste failed + placeholderElement.remove(); + }); + }); + }, + editXBlock: function(event, options) { event.preventDefault(); @@ -307,6 +368,8 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView const status = data.content?.status; if (status === "ready") { // The XBlock has been copied and is ready to use. + this.refreshPasteButton(data); // Update our UI + this.clipboardBroadcastChannel.postMessage(data); // And notify any other open tabs return data; } else if (status === "loading") { // The clipboard is being loaded asynchonously. @@ -316,6 +379,8 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView $.getJSON(clipboardEndpoint, (pollData) => { const newStatus = pollData.content?.status; if (newStatus === "ready") { + this.refreshPasteButton(data); + this.clipboardBroadcastChannel.postMessage(pollData); deferred.resolve(pollData); } else if (newStatus === "loading") { setTimeout(checkStatus, 1_000); diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 9859da0b2d..142aea5f4e 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -320,6 +320,22 @@ } } } +// New "Paste component" menu, shown on the Unit page to users with a component in their clipboard +.paste-component { + margin: $baseline ($baseline/2); + + .paste-component-button { + display: block; + width: 100%; + // Override what we're extending from ui-btn-flat-outline: + &.button { + font-size: 1.5rem; + padding: 10px 0; + } + + @extend %ui-btn-flat-outline; + } +} // outline UI // -------------------- diff --git a/cms/templates/container.html b/cms/templates/container.html index d1a743b128..49d8903b93 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -48,7 +48,8 @@ from openedx.core.djangolib.markup import HTML, Text { isUnitPage: ${is_unit_page | n, dump_js_escaped_json}, canEdit: true, - outlineURL: "${outline_url | n, js_escaped_string}" + outlineURL: "${outline_url | n, js_escaped_string}", + clipboardData: ${user_clipboard | n, dump_js_escaped_json}, } ); require(["js/models/xblock_info", "js/views/xblock", "js/views/utils/xblock_utils", "common/js/components/utils/view_utils"], function (XBlockInfo, XBlockView, XBlockUtils, ViewUtils) { diff --git a/lms/templates/studio_render_children_view.html b/lms/templates/studio_render_children_view.html index 2050631c7f..aab3d1fa7f 100644 --- a/lms/templates/studio_render_children_view.html +++ b/lms/templates/studio_render_children_view.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import gettext as _ %> % if can_reorder:
    % endif @@ -9,4 +10,10 @@ % endif % if can_add:
    + % endif diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py index e92c5a0378..e3af7494f5 100644 --- a/openedx/core/djangoapps/content_staging/api.py +++ b/openedx/core/djangoapps/content_staging/api.py @@ -1,4 +1,77 @@ """ Public python API for content staging """ -# Currently, there is no public API. +from __future__ import annotations + +from django.http import HttpRequest + +from .data import StagedContentData, StagedContentStatus, UserClipboardData +from .models import UserClipboard as _UserClipboard, StagedContent as _StagedContent +from .serializers import UserClipboardSerializer as _UserClipboardSerializer + + +def get_user_clipboard(user_id: int, only_ready: bool = True) -> UserClipboardData | None: + """ + Get the details of the user's clipboard. + + By default, will only return a value if the clipboard is READY to use for + pasting etc. Pass only_ready=False to get the clipboard data regardless. + + To get the actual OLX content, use get_staged_content_olx(content.id) + """ + try: + clipboard = _UserClipboard.objects.get(user_id=user_id) + except _UserClipboard.DoesNotExist: + # This user does not have any content on their clipboard. + return None + content = clipboard.content + if only_ready and content.status != StagedContentStatus.READY: + # The clipboard content is LOADING, ERROR, or EXPIRED + return None + return UserClipboardData( + content=StagedContentData( + id=content.id, + user_id=content.user_id, + created=content.created, + purpose=content.purpose, + status=content.status, + block_type=content.block_type, + display_name=content.display_name, + ), + source_usage_key=clipboard.source_usage_key, + ) + + +def get_user_clipboard_json(user_id: int, request: HttpRequest = None): + """ + Get the detailed status of the user's clipboard, in exactly the same format + as returned from the + /api/content-staging/v1/clipboard/ + REST API endpoint. This version of the API is meant for "preloading" that + REST API endpoint so it can be embedded in a larger response sent to the + user's browser. If you just want to get the clipboard data from python, use + get_user_clipboard() instead, since it's fully typed. + + (request is optional; including it will make the "olx_url" absolute instead + of relative.) + """ + try: + clipboard = _UserClipboard.objects.get(user_id=user_id) + except _UserClipboard.DoesNotExist: + # This user does not have any content on their clipboard. + return {"content": None, "source_usage_key": "", "source_context_title": ""} + serializer = _UserClipboardSerializer(clipboard, context={'request': request}) + return serializer.data + + +def get_staged_content_olx(staged_content_id: int) -> str | None: + """ + Get the OLX (as a string) for the given StagedContent. + + Does not check permissions! + """ + try: + sc = _StagedContent.objects.get(pk=staged_content_id) + return sc.olx + except _StagedContent.DoesNotExist: + return None diff --git a/openedx/core/djangoapps/content_staging/data.py b/openedx/core/djangoapps/content_staging/data.py new file mode 100644 index 0000000000..f913891583 --- /dev/null +++ b/openedx/core/djangoapps/content_staging/data.py @@ -0,0 +1,50 @@ +""" +Public python data types for content staging +""" +from attrs import field, frozen, validators +from datetime import datetime + +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ +from opaque_keys.edx.keys import UsageKey + + +class StagedContentStatus(TextChoices): + """ The status of this staged content. """ + # LOADING: We are actively (asynchronously) writing the OLX and related data into the staging area. + # It is not ready to be read. + LOADING = "loading", _("Loading") + # READY: The content is staged and ready to be read. + READY = "ready", _("Ready") + # The content has expired and this row can be deleted, along with any associated data. + EXPIRED = "expired", _("Expired") + # ERROR: The content could not be staged. + ERROR = "error", _("Error") + + +# Value of the "purpose" field on StagedContent objects used for clipboards. +CLIPBOARD_PURPOSE = "clipboard" +# There may be other valid values of "purpose" which aren't defined within this app. + + +@frozen +class StagedContentData: + """ + Read-only data model representing StagedContent + + (OLX content that isn't part of any course at the moment) + """ + id: int = field(validator=validators.instance_of(int)) + user_id: int = field(validator=validators.instance_of(int)) + created: datetime = field(validator=validators.instance_of(datetime)) + purpose: str = field(validator=validators.instance_of(str)) + status: StagedContentStatus = field(validator=validators.in_(StagedContentStatus), converter=StagedContentStatus) + block_type: str = field(validator=validators.instance_of(str)) + display_name: str = field(validator=validators.instance_of(str)) + + +@frozen +class UserClipboardData: + """ Read-only data model for User Clipboard data (copied OLX) """ + content: StagedContentData = field(validator=validators.instance_of(StagedContentData)) + source_usage_key: UsageKey = field(validator=validators.instance_of(UsageKey)) diff --git a/openedx/core/djangoapps/content_staging/models.py b/openedx/core/djangoapps/content_staging/models.py index c88f151bc1..9f89177f27 100644 --- a/openedx/core/djangoapps/content_staging/models.py +++ b/openedx/core/djangoapps/content_staging/models.py @@ -12,6 +12,8 @@ from opaque_keys.edx.keys import LearningContextKey from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none +from .data import CLIPBOARD_PURPOSE, StagedContentStatus + log = logging.getLogger(__name__) User = get_user_model() @@ -32,25 +34,13 @@ class StagedContent(models.Model): class Meta: verbose_name_plural = _("Staged Content") - class Status(models.TextChoices): - """ The status of this staged content. """ - # LOADING: We are actively (asynchronously) writing the OLX and related data into the staging area. - # It is not ready to be read. - LOADING = "loading", _("Loading") - # READY: The content is staged and ready to be read. - READY = "ready", _("Ready") - # The content has expired and this row can be deleted, along with any associated data. - EXPIRED = "expired", _("Expired") - # ERROR: The content could not be staged. - ERROR = "error", _("Error") - id = models.AutoField(primary_key=True) # The user that created and owns this staged content. Only this user can read it. user = models.ForeignKey(User, null=False, on_delete=models.CASCADE) created = models.DateTimeField(null=False, auto_now_add=True) # What this StagedContent is for (e.g. "clipboard" for clipboard) purpose = models.CharField(max_length=64) - status = models.CharField(max_length=20, choices=Status.choices) + status = models.CharField(max_length=20, choices=StagedContentStatus.choices) block_type = models.CharField( max_length=100, @@ -83,9 +73,6 @@ class UserClipboard(models.Model): is some OLX content that can be used in a course, such as an XBlock, a Unit, or a Subsection. """ - # value of the "purpose" field on underlying StagedContent objects - PURPOSE = "clipboard" - # The user that copied something. Clipboards are user-specific and # previously copied items are not kept. user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) @@ -114,9 +101,9 @@ class UserClipboard(models.Model): # These could probably be replaced with constraints in Django 4.1+ if self.user.id != self.content.user.id: raise ValidationError("User ID mismatch.") - if self.content.purpose != UserClipboard.PURPOSE: + if self.content.purpose != CLIPBOARD_PURPOSE: raise ValidationError( - f"StagedContent.purpose must be '{UserClipboard.PURPOSE}' to use it as clipboard content." + f"StagedContent.purpose must be '{CLIPBOARD_PURPOSE}' to use it as clipboard content." ) def save(self, *args, **kwargs): diff --git a/openedx/core/djangoapps/content_staging/serializers.py b/openedx/core/djangoapps/content_staging/serializers.py index 2a765414d3..f91e58a871 100644 --- a/openedx/core/djangoapps/content_staging/serializers.py +++ b/openedx/core/djangoapps/content_staging/serializers.py @@ -16,7 +16,7 @@ class StagedContentSerializer(serializers.ModelSerializer): model = StagedContent fields = [ 'id', - 'user', + 'user_id', 'created', 'purpose', 'status', diff --git a/openedx/core/djangoapps/content_staging/tasks.py b/openedx/core/djangoapps/content_staging/tasks.py index 850840b2d2..9df79a9a7e 100644 --- a/openedx/core/djangoapps/content_staging/tasks.py +++ b/openedx/core/djangoapps/content_staging/tasks.py @@ -7,7 +7,8 @@ import logging from celery import shared_task from celery_utils.logged_task import LoggedTask -from .models import StagedContent, UserClipboard +from .data import CLIPBOARD_PURPOSE +from .models import StagedContent log = logging.getLogger(__name__) @@ -21,5 +22,5 @@ def delete_expired_clipboards(staged_content_ids: list[int]): for pk in staged_content_ids: # Due to signal handlers deleting asset file objects from S3 or similar, # this may be "slow" relative to database speed. - StagedContent.objects.get(purpose=UserClipboard.PURPOSE, pk=pk).delete() + StagedContent.objects.get(purpose=CLIPBOARD_PURPOSE, pk=pk).delete() log.info(f"Successfully deleted StagedContent entries ({','.join(str(x) for x in staged_content_ids)})") diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 1313f05d7c..0f7622fde4 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -6,12 +6,26 @@ from xml.etree import ElementTree from rest_framework.test import APIClient from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase - from xmodule.modulestore.tests.factories import ToyCourseFactory +from openedx.core.djangoapps.content_staging import api as python_api + CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/" +# OLX of the video in the toy course using course_key.make_usage_key("video", "sample_video") +SAMPLE_VIDEO_OLX = """ +