feat: New django app for copying and pasting OLX content in Studio (#31904)

[FC-0009]
This commit is contained in:
Braden MacDonald
2023-04-14 11:41:41 -07:00
committed by GitHub
parent de047cd6f9
commit 2dc79bcab4
19 changed files with 839 additions and 3 deletions

View File

@@ -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

View File

@@ -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/",

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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('<a href="{}">{}</a>', 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'

View File

@@ -0,0 +1,4 @@
"""
Public python API for content staging
"""
# Currently, there is no public API.

View File

@@ -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',
},
},
}

View File

@@ -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

View File

@@ -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')),
],
),
]

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)})")

View File

@@ -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(),
"""
<video
url_name="sample_video"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
youtube_id_0_75="JMD_ifUUfsU"
youtube_id_1_0="OEoXaMPEzfM"
youtube_id_1_25="AKqURZnYqpk"
youtube_id_1_5="DYpADpL7jAY"
/>
"""
)
# 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("""
<html display_name="Text"><![CDATA[
<a href='/static/handouts/sample_handout.txt'>Sample</a>
]]></html>
""").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))

View File

@@ -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/<int:id>/olx', views.StagedContentOLXEndpoint.as_view(), name="staged-content-olx"),
path('clipboard/', views.ClipboardEndpoint.as_view()),
])),
]

View File

@@ -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)

View File

@@ -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