diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml
index e9703d8014..298beac2c6 100644
--- a/.github/workflows/pylint-checks.yml
+++ b/.github/workflows/pylint-checks.yml
@@ -18,7 +18,7 @@ jobs:
- module-name: lms-2
path: "--django-settings-module=lms.envs.test lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_recommendations/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py"
- module-name: openedx-1
- path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
+ path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
- module-name: openedx-2
path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/learner_pathway/ openedx/core/djangoapps/notifications/"
- module-name: common
diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json
index c250b00545..c9df5c1fb6 100644
--- a/.github/workflows/unit-test-shards.json
+++ b/.github/workflows/unit-test-shards.json
@@ -177,6 +177,7 @@
"openedx/core/djangoapps/config_model_utils/",
"openedx/core/djangoapps/content/",
"openedx/core/djangoapps/content_libraries/",
+ "openedx/core/djangoapps/content_staging/",
"openedx/core/djangoapps/contentserver/",
"openedx/core/djangoapps/cookie_metadata/",
"openedx/core/djangoapps/course_apps/",
diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js
index 4ce217ff0d..15c92fac65 100644
--- a/cms/static/js/views/pages/container.js
+++ b/cms/static/js/views/pages/container.js
@@ -295,8 +295,43 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView
copyXBlock: function(event) {
event.preventDefault();
- // This is a new feature, hidden behind a feature flag.
- alert("Copying of XBlocks is coming soon.");
+ const clipboardEndpoint = "/api/content-staging/v1/clipboard/";
+ const element = this.findXBlockElement(event.target);
+ const usageKeyToCopy = element.data('locator');
+ // Start showing a "Copying" notification:
+ ViewUtils.runOperationShowingMessage(gettext('Copying'), () => {
+ return $.postJSON(
+ clipboardEndpoint,
+ { usage_key: usageKeyToCopy },
+ ).then((data) => {
+ const status = data.content?.status;
+ if (status === "ready") {
+ // The XBlock has been copied and is ready to use.
+ return data;
+ } else if (status === "loading") {
+ // The clipboard is being loaded asynchonously.
+ // Poll the endpoint until the copying process is complete:
+ const deferred = $.Deferred();
+ const checkStatus = () => {
+ $.getJSON(clipboardEndpoint, (pollData) => {
+ const newStatus = pollData.content?.status;
+ if (newStatus === "ready") {
+ deferred.resolve(pollData);
+ } else if (newStatus === "loading") {
+ setTimeout(checkStatus, 1_000);
+ } else {
+ deferred.reject();
+ throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`);
+ }
+ })
+ }
+ setTimeout(checkStatus, 1_000);
+ return deferred;
+ } else {
+ throw new Error(`Unexpected clipboard status "${status}" in successful API response.`);
+ }
+ });
+ });
},
duplicateComponent: function(xblockElement) {
diff --git a/openedx/core/djangoapps/content_staging/README.rst b/openedx/core/djangoapps/content_staging/README.rst
new file mode 100644
index 0000000000..71c43c4bff
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/README.rst
@@ -0,0 +1,32 @@
+=============================
+Content Staging and Clipboard
+=============================
+
+This django app provides APIs to temporarily store content and then retrieve it
+for use. The content must be detached (not yet part of any course/library) and
+read-only (this is not a workspace, and the content cannot be edited while
+staged).
+
+The primary use case, which is also integrated into this django app, is a
+per-user clipboard in the CMS (Studio), which can be used to copy and paste
+components (XBlocks) between courses. At the moment, we only support leaf
+XBlocks in courses but the goal is to soon support larger pieces of content as
+well as content libraries.
+
+As this app is designed only for ephemeral use cases, API consumers should not
+expect that staged content will be stored for longer than 24 hours.
+
+This app is part of the CMS and is not intended to work with the LMS. It may be
+moved from the CMS into the future Learning Core project.
+
+---------------
+Clipboard Usage
+---------------
+
+* When a user initiates a "Copy" action, the usage key of the component that
+ they wish to copy is POSTed to this app's ``copy`` REST API endpoint.
+* This app will then use the ``olx_rest_api`` to get the OLX of the item in
+ question, as well as other required data like metadata, static assets, etc.
+* If the copying action succeeded or is still in progress, a clipboard ID will
+ be returned. There is also an API available to query the "current" clipboard
+ ID of the user. Clipboard are always user-specific and private to a user.
diff --git a/openedx/core/djangoapps/content_staging/__init__.py b/openedx/core/djangoapps/content_staging/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/content_staging/admin.py b/openedx/core/djangoapps/content_staging/admin.py
new file mode 100644
index 0000000000..6cc567d87d
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/admin.py
@@ -0,0 +1,34 @@
+"""
+Admin views for Staged Content and Clipboard
+"""
+from django.contrib import admin
+from django.urls import reverse
+from django.utils.html import format_html
+from .models import StagedContent, UserClipboard
+
+
+@admin.register(StagedContent)
+class StagedContentAdmin(admin.ModelAdmin):
+ """ Admin config for StagedContent """
+ list_display = ('id', 'user', 'created', 'purpose', 'status', 'block_type', 'display_name', 'suggested_url_name')
+ list_filter = ('purpose', 'status', 'block_type')
+ search_fields = ('user__username', 'display_name', 'suggested_url_name')
+ readonly_fields = ('id', 'user', 'created', 'purpose', 'status', 'block_type', 'olx')
+
+
+@admin.register(UserClipboard)
+class UserClipboardAdmin(admin.ModelAdmin):
+ """ Admin config for UserClipboard """
+ list_display = ('user', 'content_link', 'source_usage_key', 'get_source_context_title')
+ search_fields = ('user__username', 'source_usage_key', 'content__display_name')
+ readonly_fields = ('source_context_key', 'get_source_context_title')
+
+ def content_link(self, obj):
+ """ Display the StagedContent object as a link """
+ url = reverse('admin:content_staging_stagedcontent_change', args=[obj.content.pk])
+ return format_html('{}', url, obj.content)
+ content_link.short_description = 'Content'
+
+ def get_source_context_title(self, obj):
+ return obj.get_source_context_title()
+ get_source_context_title.short_description = 'Source Context Title'
diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py
new file mode 100644
index 0000000000..e92c5a0378
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/api.py
@@ -0,0 +1,4 @@
+"""
+Public python API for content staging
+"""
+# Currently, there is no public API.
diff --git a/openedx/core/djangoapps/content_staging/apps.py b/openedx/core/djangoapps/content_staging/apps.py
new file mode 100644
index 0000000000..2bb119187f
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/apps.py
@@ -0,0 +1,26 @@
+"""
+content_staging Django application initialization.
+"""
+from django.apps import AppConfig
+from edx_django_utils.plugins import PluginURLs
+
+from openedx.core.djangoapps.plugins.constants import ProjectType
+
+
+class ContentStagingAppConfig(AppConfig):
+ """
+ Configuration for the content_staging Django plugin application.
+ See: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst
+ """
+
+ name = 'openedx.core.djangoapps.content_staging'
+ verbose_name = 'Content Staging (and clipboard) API'
+ plugin_app = {
+ PluginURLs.CONFIG: {
+ ProjectType.CMS: {
+ PluginURLs.NAMESPACE: '',
+ PluginURLs.REGEX: '',
+ PluginURLs.RELATIVE_PATH: 'urls',
+ },
+ },
+ }
diff --git a/openedx/core/djangoapps/content_staging/block_serializer.py b/openedx/core/djangoapps/content_staging/block_serializer.py
new file mode 100644
index 0000000000..f3d9d84538
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/block_serializer.py
@@ -0,0 +1,99 @@
+"""
+Code for serializing a modulestore XBlock to OLX suitable for import into
+Blockstore.
+"""
+import logging
+import os
+from collections import namedtuple
+
+from lxml import etree
+
+from openedx.core.djangoapps.olx_rest_api import adapters
+
+log = logging.getLogger(__name__)
+
+# A static file required by an XBlock
+StaticFile = namedtuple('StaticFile', ['name', 'url', 'data'])
+
+
+class XBlockSerializer:
+ """
+ A class that can serializer an XBlock to OLX
+ """
+ # TEMP: this needs to be consolidated with the XBlockSerializer in olx_rest_api.
+ # i.e. have one base serializer, and a derived blockstore serializer
+
+ def __init__(self, block):
+ """
+ Serialize an XBlock to an OLX string + supporting files, and store the
+ resulting data in this object.
+ """
+ self.orig_block_key = block.scope_ids.usage_id
+ self.static_files = []
+ olx_node = self.serialize_block(block)
+ self.olx_str = etree.tostring(olx_node, encoding="unicode", pretty_print=True)
+
+ course_key = self.orig_block_key.course_key
+ # Search the OLX for references to files stored in the course's
+ # "Files & Uploads" (contentstore):
+ self.olx_str = adapters.rewrite_absolute_static_urls(self.olx_str, course_key)
+ for asset in adapters.collect_assets_from_text(self.olx_str, course_key):
+ path = asset['path']
+ if path not in [sf.name for sf in self.static_files]:
+ self.static_files.append(StaticFile(name=path, url=asset['url'], data=None))
+
+ def serialize_block(self, block) -> etree.Element:
+ if self.orig_block_key.block_type == 'html':
+ return self.serialize_html_block(block)
+ else:
+ return self.serialize_normal_block(block)
+
+ def serialize_normal_block(self, block) -> etree.Element:
+ """
+ Serialize an XBlock to XML.
+
+ This method is used for every block type except HTML, which uses
+ serialize_html_block() instead.
+ """
+ # Create an XML node to hold the exported data
+ olx_node = etree.Element("root") # The node name doesn't matter: add_xml_to_node will change it
+ # ^ Note: We could pass nsmap=xblock.core.XML_NAMESPACES here, but the
+ # resulting XML namespace attributes don't seem that useful?
+ with adapters.override_export_fs(block) as filesystem: # Needed for XBlocks that inherit XModuleDescriptor
+ # Tell the block to serialize itself as XML/OLX:
+ if not block.has_children:
+ block.add_xml_to_node(olx_node)
+ else:
+ # We don't want the children serialized at this time, because
+ # otherwise we can't tell which files in 'filesystem' belong to
+ # this block and which belong to its children. So, temporarily
+ # disable any children:
+ children = block.children
+ block.children = []
+ block.add_xml_to_node(olx_node)
+ block.children = children
+
+ # Now the block may have exported addtional data as files in
+ # 'filesystem'. If so, store them:
+ for item in filesystem.walk(): # pylint: disable=not-callable
+ for unit_file in item.files:
+ file_path = os.path.join(item.path, unit_file.name)
+ with filesystem.open(file_path, 'rb') as fh:
+ data = fh.read()
+ self.static_files.append(StaticFile(name=unit_file.name, data=data, url=None))
+ # Recursively serialize the children:
+ if block.has_children:
+ for child in block.get_children():
+ child_node = self.serialize_block(child)
+ olx_node.append(child_node)
+ return olx_node
+
+ def serialize_html_block(self, block) -> etree.Element:
+ """
+ Special case handling for HTML blocks
+ """
+ olx_node = etree.Element("html")
+ if block.display_name:
+ olx_node.attrib["display_name"] = block.display_name
+ olx_node.text = etree.CDATA("\n" + block.data + "\n")
+ return olx_node
diff --git a/openedx/core/djangoapps/content_staging/migrations/0001_initial.py b/openedx/core/djangoapps/content_staging/migrations/0001_initial.py
new file mode 100644
index 0000000000..fa777a60cc
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/migrations/0001_initial.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.2.18 on 2023-03-16 23:45
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import opaque_keys.edx.django.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='StagedContent',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('purpose', models.CharField(max_length=64)),
+ ('status', models.CharField(choices=[('loading', 'Loading'), ('ready', 'Ready'), ('expired', 'Expired'), ('error', 'Error')], max_length=20)),
+ ('block_type', models.CharField(help_text='\n What type of content is staged. Only OLX content is supported, and\n this field must be the same as the root tag of the OLX.\n e.g. "video" if a video is staged, or "vertical" for a unit.\n ', max_length=100)),
+ ('olx', models.TextField()),
+ ('display_name', models.CharField(max_length=1024)),
+ ('suggested_url_name', models.CharField(max_length=1024)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name_plural': 'Staged Content',
+ },
+ ),
+ migrations.CreateModel(
+ name='UserClipboard',
+ fields=[
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='auth.user')),
+ ('source_usage_key', opaque_keys.edx.django.models.UsageKeyField(help_text='Original usage key/ID of the thing that is in the clipboard.', max_length=255)),
+ ('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content_staging.stagedcontent')),
+ ],
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content_staging/migrations/__init__.py b/openedx/core/djangoapps/content_staging/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/content_staging/models.py b/openedx/core/djangoapps/content_staging/models.py
new file mode 100644
index 0000000000..c88f151bc1
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/models.py
@@ -0,0 +1,126 @@
+"""
+Models for content staging (and clipboard)
+"""
+import logging
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from opaque_keys.edx.django.models import UsageKeyField
+from opaque_keys.edx.keys import LearningContextKey
+
+from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
+
+log = logging.getLogger(__name__)
+
+User = get_user_model()
+
+
+class StagedContent(models.Model):
+ """
+ Each StagedContent instance represents a "piece" of content (e.g. a single
+ XBlock, or a single Unit, or a single Subsection of a course) that is not
+ currently part of any course or library, but which is available to be copied
+ into a course or library.
+
+ Use as a clipboard: for any given user, the most recent row with
+ purpose=CLIPBOARD is the "current" clipboard content. But it can only be
+ pasted if its status is READY.
+ """
+
+ 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)
+
+ block_type = models.CharField(
+ max_length=100,
+ help_text=_("""
+ What type of content is staged. Only OLX content is supported, and
+ this field must be the same as the root tag of the OLX.
+ e.g. "video" if a video is staged, or "vertical" for a unit.
+ """),
+ )
+ olx = models.TextField(null=False, blank=False)
+ # The display name of whatever item is staged here, i.e. the root XBlock.
+ display_name = models.CharField(max_length=1024)
+ # A _suggested_ URL name to use for this content. Since this suggestion may already be in use, it's fine to generate
+ # a new url_name instead.
+ suggested_url_name = models.CharField(max_length=1024)
+
+ @property
+ def olx_filename(self) -> str:
+ """ Get a filename that can be used for the OLX content of this staged content """
+ return f"{self.suggested_url_name}.xml"
+
+ def __str__(self):
+ """ String representation of this instance """
+ return f'Staged {self.block_type} block "{self.display_name}" ({self.status})'
+
+
+class UserClipboard(models.Model):
+ """
+ Each user has a clipboard that can hold one item at a time, where an item
+ 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)
+ content = models.ForeignKey(StagedContent, on_delete=models.CASCADE)
+ source_usage_key = UsageKeyField(
+ max_length=255,
+ help_text=_("Original usage key/ID of the thing that is in the clipboard."),
+ )
+
+ @property
+ def source_context_key(self) -> LearningContextKey:
+ """ Get the context (course/library) that this was copied from """
+ return self.source_usage_key.context_key
+
+ def get_source_context_title(self) -> str:
+ """ Get the title of the source context, if any """
+ if self.source_context_key.is_course:
+ course_overview = get_course_overview_or_none(self.source_context_key)
+ if course_overview:
+ return course_overview.display_name_with_default
+ # Just return the ID as the name, if it's empty or is not a course.
+ return str(self.source_context_key)
+
+ def clean(self):
+ """ Check that this model is being used correctly. """
+ # 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:
+ raise ValidationError(
+ f"StagedContent.purpose must be '{UserClipboard.PURPOSE}' to use it as clipboard content."
+ )
+
+ def save(self, *args, **kwargs):
+ """ Save this model instance """
+ # Enforce checks on save:
+ self.full_clean()
+ return super().save(*args, **kwargs)
diff --git a/openedx/core/djangoapps/content_staging/serializers.py b/openedx/core/djangoapps/content_staging/serializers.py
new file mode 100644
index 0000000000..2a765414d3
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/serializers.py
@@ -0,0 +1,45 @@
+"""
+Serializers for the content libraries REST API
+"""
+from rest_framework import serializers
+
+from .models import StagedContent
+
+
+class StagedContentSerializer(serializers.ModelSerializer):
+ """
+ Serializer for staged content. Doesn't include the OLX by default.
+ """
+ olx_url = serializers.HyperlinkedIdentityField(view_name="staged-content-olx", lookup_field="id")
+
+ class Meta:
+ model = StagedContent
+ fields = [
+ 'id',
+ 'user',
+ 'created',
+ 'purpose',
+ 'status',
+ 'block_type',
+ # We don't include OLX; it may be large. But we include the URL to retrieve it.
+ 'olx_url',
+ 'display_name',
+ ]
+
+
+class UserClipboardSerializer(serializers.Serializer):
+ """
+ Serializer for the status of the user's clipboard
+ """
+ content = StagedContentSerializer(allow_null=True)
+ source_usage_key = serializers.CharField(allow_blank=True)
+ # The title of the course that the content came from originally, if relevant
+ source_context_title = serializers.CharField(allow_blank=True, source="get_source_context_title")
+
+
+class PostToClipboardSerializer(serializers.Serializer):
+ """
+ Serializer for the POST request body when putting a new XBlock into the
+ user's clipboard.
+ """
+ usage_key = serializers.CharField(help_text="Usage key to copy into the clipboard")
diff --git a/openedx/core/djangoapps/content_staging/tasks.py b/openedx/core/djangoapps/content_staging/tasks.py
new file mode 100644
index 0000000000..850840b2d2
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/tasks.py
@@ -0,0 +1,25 @@
+"""
+Celery tasks for Content Staging.
+"""
+from __future__ import annotations # for list[int] type
+import logging
+
+from celery import shared_task
+from celery_utils.logged_task import LoggedTask
+
+from .models import StagedContent, UserClipboard
+
+log = logging.getLogger(__name__)
+
+
+@shared_task(base=LoggedTask)
+def delete_expired_clipboards(staged_content_ids: list[int]):
+ """
+ A Celery task to delete StagedContent clipboard entries that are no longer
+ relevant.
+ """
+ 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()
+ 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/__init__.py b/openedx/core/djangoapps/content_staging/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
new file mode 100644
index 0000000000..4e042199a6
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
@@ -0,0 +1,199 @@
+"""
+Tests for the clipboard functionality
+"""
+from textwrap import dedent
+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
+
+
+CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
+
+
+class ClipboardTestCase(ModuleStoreTestCase):
+ """
+ Test Clipboard functionality
+ """
+
+ def test_empty_clipboard(self):
+ """
+ When a user has no content on their clipboard, we get an empty 200 response
+ """
+ client = APIClient()
+ client.login(username=self.user.username, password=self.user_password)
+ response = client.get(CLIPBOARD_ENDPOINT)
+ # We don't consider this a 404 error, it's a 200 with an empty response
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), {
+ "content": None,
+ "source_usage_key": "",
+ "source_context_title": ""
+ })
+
+ 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)
+
+ # Initial conditions: clipboard is empty:
+ response = client.get(CLIPBOARD_ENDPOINT)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["content"], None)
+
+ return (course_key, client)
+
+ def test_copy_video(self):
+ """
+ Test copying a video from the course
+ """
+ course_key, client = self._setup_course()
+
+ # Copy the video
+ video_key = course_key.make_usage_key("video", "sample_video")
+ response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
+
+ # Validate the response:
+ self.assertEqual(response.status_code, 200)
+ response_data = response.json()
+ self.assertEqual(response_data["source_usage_key"], str(video_key))
+ self.assertEqual(response_data["source_context_title"], "Toy Course")
+ self.assertEqual(response_data["content"], {**response_data["content"], **{
+ "block_type": "video",
+ # To ensure API stability, we are hard-coding these expected values:
+ "purpose": "clipboard",
+ "status": "ready",
+ "display_name": "default", # Weird name but that's what defined in the toy course
+ }})
+ # Test the actual OLX in the clipboard:
+ olx_url = response_data["content"]["olx_url"]
+ olx_response = client.get(olx_url)
+ self.assertEqual(olx_response.status_code, 200)
+ self.assertEqual(olx_response.get("Content-Type"), "application/vnd.openedx.xblock.v1.video+xml")
+ self.assertXmlEqual(
+ olx_response.content.decode(),
+ """
+
+ """
+ )
+
+ # Now if we GET the clipboard again, the GET response should exactly equal the last POST response:
+ self.assertEqual(client.get(CLIPBOARD_ENDPOINT).json(), response_data)
+
+ def test_copy_html(self):
+ """
+ Test copying an HTML from the course
+ """
+ course_key, client = self._setup_course()
+
+ # Copy the video
+ html_key = course_key.make_usage_key("html", "toyhtml")
+ response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
+
+ # Validate the response:
+ self.assertEqual(response.status_code, 200)
+ response_data = response.json()
+ self.assertEqual(response_data["source_usage_key"], str(html_key))
+ self.assertEqual(response_data["source_context_title"], "Toy Course")
+ self.assertEqual(response_data["content"], {**response_data["content"], **{
+ "block_type": "html",
+ # To ensure API stability, we are hard-coding these expected values:
+ "purpose": "clipboard",
+ "status": "ready",
+ "display_name": "Text", # Has no display_name set so we fallback to this default
+ }})
+ # Test the actual OLX in the clipboard:
+ olx_url = response_data["content"]["olx_url"]
+ olx_response = client.get(olx_url)
+ self.assertEqual(olx_response.status_code, 200)
+ self.assertEqual(olx_response.get("Content-Type"), "application/vnd.openedx.xblock.v1.html+xml")
+ # For HTML, we really want to be sure that the OLX is serialized in this exact format (using CDATA), so we check
+ # the actual string directly rather than using assertXmlEqual():
+ self.assertEqual(olx_response.content.decode(), dedent("""
+ Sample
+ ]]>
+ """).lstrip())
+
+ # Now if we GET the clipboard again, the GET response should exactly equal the last POST response:
+ self.assertEqual(client.get(CLIPBOARD_ENDPOINT).json(), response_data)
+
+ def test_copy_several_things(self):
+ """
+ Test that the clipboard only holds one thing at a time.
+ """
+ course_key, client = self._setup_course()
+
+ # Copy the video and validate the response:
+ video_key = course_key.make_usage_key("video", "sample_video")
+ response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
+ self.assertEqual(response.status_code, 200)
+ video_clip_data = response.json()
+ self.assertEqual(video_clip_data["source_usage_key"], str(video_key))
+ self.assertEqual(video_clip_data["content"]["block_type"], "video")
+ old_olx_url = video_clip_data["content"]["olx_url"]
+ self.assertEqual(client.get(old_olx_url).status_code, 200)
+
+ # Now copy some HTML:
+ html_key = course_key.make_usage_key("html", "toyhtml")
+ response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
+ self.assertEqual(response.status_code, 200)
+
+ # Now check the clipboard:
+ response = client.get(CLIPBOARD_ENDPOINT)
+ html_clip_data = response.json()
+ self.assertEqual(html_clip_data["source_usage_key"], str(html_key))
+ self.assertEqual(html_clip_data["content"]["block_type"], "html")
+
+ # The OLX link from the video will no longer work:
+ self.assertEqual(client.get(old_olx_url).status_code, 404)
+
+ def test_no_course_permission(self):
+ """
+ Test that a user without read access cannot copy items in a course
+ """
+ course_key = ToyCourseFactory.create().id
+ nonstaff_client = APIClient()
+ nonstaff_username, nonstaff_password = self.create_non_staff_user()
+ nonstaff_client.login(username=nonstaff_username, password=nonstaff_password)
+
+ # Try copying the video as a non-staff user:
+ html_key = course_key.make_usage_key("html", "toyhtml")
+ with self.allow_transaction_exception():
+ response = nonstaff_client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
+ self.assertEqual(response.status_code, 403)
+ response = nonstaff_client.get(CLIPBOARD_ENDPOINT)
+ self.assertEqual(response.json()["content"], None)
+
+ def test_no_stealing_clipboard_content(self):
+ """
+ Test that a user cannot see another user's clipboard
+ """
+ course_key, client = self._setup_course()
+ nonstaff_client = APIClient()
+ nonstaff_username, nonstaff_password = self.create_non_staff_user()
+ nonstaff_client.login(username=nonstaff_username, password=nonstaff_password)
+
+ # The regular user copies something to their clipboard:
+ html_key = course_key.make_usage_key("html", "toyhtml")
+ response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
+ # Then another user tries to get the OLX:
+ olx_url = response.json()["content"]["olx_url"]
+ response = nonstaff_client.get(olx_url)
+ self.assertEqual(response.status_code, 403)
+
+ def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool:
+ """ Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """
+ self.assertEqual(ElementTree.canonicalize(xml_str_a), ElementTree.canonicalize(xml_str_b))
diff --git a/openedx/core/djangoapps/content_staging/urls.py b/openedx/core/djangoapps/content_staging/urls.py
new file mode 100644
index 0000000000..8953d68de3
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/urls.py
@@ -0,0 +1,13 @@
+"""
+Studio URL configuration for Content Staging (& Clipboard)
+"""
+from django.urls import path, include
+
+from . import views
+
+urlpatterns = [
+ path('api/content-staging/v1/', include([
+ path('staged-content//olx', views.StagedContentOLXEndpoint.as_view(), name="staged-content-olx"),
+ path('clipboard/', views.ClipboardEndpoint.as_view()),
+ ])),
+]
diff --git a/openedx/core/djangoapps/content_staging/views.py b/openedx/core/djangoapps/content_staging/views.py
new file mode 100644
index 0000000000..96eae3db71
--- /dev/null
+++ b/openedx/core/djangoapps/content_staging/views.py
@@ -0,0 +1,152 @@
+"""
+REST API views for content staging
+"""
+import logging
+
+from django.db import transaction
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+import edx_api_doc_tools as apidocs
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import UsageKey
+from opaque_keys.edx.locator import CourseLocator
+from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from common.djangoapps.student.auth import has_studio_read_access
+
+from openedx.core.lib.api.view_utils import view_auth_classes
+from xmodule import block_metadata_utils
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import ItemNotFoundError
+
+from .block_serializer import XBlockSerializer
+from .models import StagedContent, UserClipboard
+from .serializers import UserClipboardSerializer, PostToClipboardSerializer
+from .tasks import delete_expired_clipboards
+
+log = logging.getLogger(__name__)
+
+
+@view_auth_classes(is_authenticated=True)
+class StagedContentOLXEndpoint(APIView):
+ """
+ API Endpoint to get the OLX of any given StagedContent.
+ """
+
+ def get(self, request, id): # pylint: disable=redefined-builtin
+ """
+ Get the OLX of the given StagedContent object.
+ """
+ staged_content = get_object_or_404(StagedContent, pk=id)
+ if staged_content.user.id != request.user.id:
+ raise PermissionDenied("Users can only access their own staged content")
+ if staged_content.status != StagedContent.Status.READY:
+ # If the status is LOADING, the OLX may not be generated/valid yet.
+ # If the status is ERROR or EXPIRED, this row is no longer usable.
+ raise NotFound("The requested content is not available.")
+ return HttpResponse(staged_content.olx, headers={
+ "Content-Type": f"application/vnd.openedx.xblock.v1.{staged_content.block_type}+xml",
+ "Content-Disposition": f'attachment; filename="{staged_content.olx_filename}"',
+ })
+
+
+@method_decorator(transaction.non_atomic_requests, name='dispatch')
+@view_auth_classes(is_authenticated=True)
+class ClipboardEndpoint(APIView):
+ """
+ API Endpoint that can be used to get the status of the current user's
+ clipboard or to POST some content to the clipboard.
+ """
+
+ @apidocs.schema(
+ responses={
+ 200: UserClipboardSerializer,
+ }
+ )
+ def get(self, request):
+ """
+ Get the detailed status of the user's clipboard. This does not return the OLX.
+ """
+ try:
+ clipboard = UserClipboard.objects.get(user=request.user.id)
+ except UserClipboard.DoesNotExist:
+ # This user does not have any content on their clipboard.
+ return Response({"content": None, "source_usage_key": "", "source_context_title": ""})
+ serializer = UserClipboardSerializer(clipboard, context={"request": request})
+ return Response(serializer.data)
+
+ @apidocs.schema(
+ body=PostToClipboardSerializer,
+ responses={
+ 200: UserClipboardSerializer,
+ 403: "You do not have permission to read the specified usage key.",
+ 404: "The requested usage key does not exist.",
+ },
+ )
+ def post(self, request):
+ """
+ Put some piece of content into the user's clipboard.
+ """
+ # Check if the content exists and the user has permission to read it.
+ # Parse the usage key:
+ try:
+ usage_key = UsageKey.from_string(request.data["usage_key"])
+ except (ValueError, InvalidKeyError):
+ raise ValidationError('Invalid usage key') # lint-amnesty, pylint: disable=raise-missing-from
+ if usage_key.block_type in ('course', 'chapter', 'sequential'):
+ raise ValidationError('Requested XBlock tree is too large')
+ course_key = usage_key.context_key
+ if not isinstance(course_key, CourseLocator):
+ # In the future, we'll support libraries too but for now we don't.
+ raise ValidationError('Invalid usage key: not a modulestore course')
+ # Make sure the user has permission on that course
+ if not has_studio_read_access(request.user, course_key):
+ raise PermissionDenied("You must be a member of the course team in Studio to export OLX using this API.")
+
+ # Get the OLX of the content
+ try:
+ block = modulestore().get_item(usage_key)
+ except ItemNotFoundError as exc:
+ raise NotFound("The requested usage key does not exist.") from exc
+ block_data = XBlockSerializer(block)
+
+ expired_ids = []
+ with transaction.atomic():
+ # Mark all of the user's existing StagedContent rows as EXPIRED
+ to_expire = StagedContent.objects.filter(
+ user=request.user,
+ purpose=UserClipboard.PURPOSE,
+ ).exclude(
+ status=StagedContent.Status.EXPIRED,
+ )
+ for sc in to_expire:
+ expired_ids.append(sc.id)
+ sc.status = StagedContent.Status.EXPIRED
+ sc.save()
+ # Insert a new StagedContent row for this
+ staged_content = StagedContent.objects.create(
+ user=request.user,
+ purpose=UserClipboard.PURPOSE,
+ status=StagedContent.Status.READY,
+ block_type=usage_key.block_type,
+ olx=block_data.olx_str,
+ display_name=block_metadata_utils.display_name_with_default(block),
+ suggested_url_name=usage_key.block_id,
+ )
+ (clipboard, _created) = UserClipboard.objects.update_or_create(user=request.user, defaults={
+ "content": staged_content,
+ "source_usage_key": usage_key,
+ })
+ # Return the current clipboard exactly as if GET was called:
+ serializer = UserClipboardSerializer(clipboard, context={"request": request})
+ # Log an event so we can analyze how this feature is used:
+ log.info(f"Copied {usage_key.block_type} component \"{usage_key}\" to their clipboard.")
+ # Enqueue a (potentially slow) task to delete the old staged content
+ try:
+ delete_expired_clipboards.delay(expired_ids)
+ except Exception as err: # pylint: disable=broad-except
+ log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}")
+ # Return the response:
+ return Response(serializer.data)
diff --git a/setup.py b/setup.py
index 3a6670c041..02682ad619 100644
--- a/setup.py
+++ b/setup.py
@@ -157,6 +157,7 @@ setup(
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",
"content_libraries = openedx.core.djangoapps.content_libraries.apps:ContentLibrariesConfig",
+ "content_staging = openedx.core.djangoapps.content_staging.apps:ContentStagingAppConfig",
# Importing an LMS app into the Studio process is not a good
# practice. We're ignoring this for Discussions here because its
# placement in LMS is a historical artifact. The eventual goal is to