From 2dc79bcab42dafed2c122eb808cdd5604327c890 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 14 Apr 2023 11:41:41 -0700 Subject: [PATCH] feat: New django app for copying and pasting OLX content in Studio (#31904) [FC-0009] --- .github/workflows/pylint-checks.yml | 2 +- .github/workflows/unit-test-shards.json | 1 + cms/static/js/views/pages/container.js | 39 +++- .../djangoapps/content_staging/README.rst | 32 +++ .../djangoapps/content_staging/__init__.py | 0 .../core/djangoapps/content_staging/admin.py | 34 +++ .../core/djangoapps/content_staging/api.py | 4 + .../core/djangoapps/content_staging/apps.py | 26 +++ .../content_staging/block_serializer.py | 99 +++++++++ .../migrations/0001_initial.py | 44 ++++ .../content_staging/migrations/__init__.py | 0 .../core/djangoapps/content_staging/models.py | 126 +++++++++++ .../djangoapps/content_staging/serializers.py | 45 ++++ .../core/djangoapps/content_staging/tasks.py | 25 +++ .../content_staging/tests/__init__.py | 0 .../content_staging/tests/test_clipboard.py | 199 ++++++++++++++++++ .../core/djangoapps/content_staging/urls.py | 13 ++ .../core/djangoapps/content_staging/views.py | 152 +++++++++++++ setup.py | 1 + 19 files changed, 839 insertions(+), 3 deletions(-) create mode 100644 openedx/core/djangoapps/content_staging/README.rst create mode 100644 openedx/core/djangoapps/content_staging/__init__.py create mode 100644 openedx/core/djangoapps/content_staging/admin.py create mode 100644 openedx/core/djangoapps/content_staging/api.py create mode 100644 openedx/core/djangoapps/content_staging/apps.py create mode 100644 openedx/core/djangoapps/content_staging/block_serializer.py create mode 100644 openedx/core/djangoapps/content_staging/migrations/0001_initial.py create mode 100644 openedx/core/djangoapps/content_staging/migrations/__init__.py create mode 100644 openedx/core/djangoapps/content_staging/models.py create mode 100644 openedx/core/djangoapps/content_staging/serializers.py create mode 100644 openedx/core/djangoapps/content_staging/tasks.py create mode 100644 openedx/core/djangoapps/content_staging/tests/__init__.py create mode 100644 openedx/core/djangoapps/content_staging/tests/test_clipboard.py create mode 100644 openedx/core/djangoapps/content_staging/urls.py create mode 100644 openedx/core/djangoapps/content_staging/views.py 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(), + """ +