feat: Paste Components (OLX) into any Unit in Studio (#31969)
* feat: Implement paste button * chore: improve docs and add tests for python API * fix: drive-by fix to use a better API for comparing XML * feat: track which XBlock something was copied from * feat: add tests * feat: enable import linter so content_staging's public API is respected * fix: error seen when trying to paste drag-and-drop-v2 blocks * fix: use strip_text=True consistently for XML comparisons * refactor: rename get_user_clipboard_status to get_user_clipboard * feat: Better error reporting when pasting in Studio * chore: convert new test suite to pytest assertions * refactor: push READY status check into the API per review suggestion * fix: use strip_text=True consistently for XML comparisons * fix: store "copied_from_block" as a string to avoid Reference field issues * fix: minor lint error * refactor: move data types to data.py per OEP-49
This commit is contained in:
@@ -1,4 +1,77 @@
|
||||
"""
|
||||
Public python API for content staging
|
||||
"""
|
||||
# Currently, there is no public API.
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from .data import StagedContentData, StagedContentStatus, UserClipboardData
|
||||
from .models import UserClipboard as _UserClipboard, StagedContent as _StagedContent
|
||||
from .serializers import UserClipboardSerializer as _UserClipboardSerializer
|
||||
|
||||
|
||||
def get_user_clipboard(user_id: int, only_ready: bool = True) -> UserClipboardData | None:
|
||||
"""
|
||||
Get the details of the user's clipboard.
|
||||
|
||||
By default, will only return a value if the clipboard is READY to use for
|
||||
pasting etc. Pass only_ready=False to get the clipboard data regardless.
|
||||
|
||||
To get the actual OLX content, use get_staged_content_olx(content.id)
|
||||
"""
|
||||
try:
|
||||
clipboard = _UserClipboard.objects.get(user_id=user_id)
|
||||
except _UserClipboard.DoesNotExist:
|
||||
# This user does not have any content on their clipboard.
|
||||
return None
|
||||
content = clipboard.content
|
||||
if only_ready and content.status != StagedContentStatus.READY:
|
||||
# The clipboard content is LOADING, ERROR, or EXPIRED
|
||||
return None
|
||||
return UserClipboardData(
|
||||
content=StagedContentData(
|
||||
id=content.id,
|
||||
user_id=content.user_id,
|
||||
created=content.created,
|
||||
purpose=content.purpose,
|
||||
status=content.status,
|
||||
block_type=content.block_type,
|
||||
display_name=content.display_name,
|
||||
),
|
||||
source_usage_key=clipboard.source_usage_key,
|
||||
)
|
||||
|
||||
|
||||
def get_user_clipboard_json(user_id: int, request: HttpRequest = None):
|
||||
"""
|
||||
Get the detailed status of the user's clipboard, in exactly the same format
|
||||
as returned from the
|
||||
/api/content-staging/v1/clipboard/
|
||||
REST API endpoint. This version of the API is meant for "preloading" that
|
||||
REST API endpoint so it can be embedded in a larger response sent to the
|
||||
user's browser. If you just want to get the clipboard data from python, use
|
||||
get_user_clipboard() instead, since it's fully typed.
|
||||
|
||||
(request is optional; including it will make the "olx_url" absolute instead
|
||||
of relative.)
|
||||
"""
|
||||
try:
|
||||
clipboard = _UserClipboard.objects.get(user_id=user_id)
|
||||
except _UserClipboard.DoesNotExist:
|
||||
# This user does not have any content on their clipboard.
|
||||
return {"content": None, "source_usage_key": "", "source_context_title": ""}
|
||||
serializer = _UserClipboardSerializer(clipboard, context={'request': request})
|
||||
return serializer.data
|
||||
|
||||
|
||||
def get_staged_content_olx(staged_content_id: int) -> str | None:
|
||||
"""
|
||||
Get the OLX (as a string) for the given StagedContent.
|
||||
|
||||
Does not check permissions!
|
||||
"""
|
||||
try:
|
||||
sc = _StagedContent.objects.get(pk=staged_content_id)
|
||||
return sc.olx
|
||||
except _StagedContent.DoesNotExist:
|
||||
return None
|
||||
|
||||
50
openedx/core/djangoapps/content_staging/data.py
Normal file
50
openedx/core/djangoapps/content_staging/data.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Public python data types for content staging
|
||||
"""
|
||||
from attrs import field, frozen, validators
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.models import TextChoices
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
|
||||
|
||||
class StagedContentStatus(TextChoices):
|
||||
""" The status of this staged content. """
|
||||
# LOADING: We are actively (asynchronously) writing the OLX and related data into the staging area.
|
||||
# It is not ready to be read.
|
||||
LOADING = "loading", _("Loading")
|
||||
# READY: The content is staged and ready to be read.
|
||||
READY = "ready", _("Ready")
|
||||
# The content has expired and this row can be deleted, along with any associated data.
|
||||
EXPIRED = "expired", _("Expired")
|
||||
# ERROR: The content could not be staged.
|
||||
ERROR = "error", _("Error")
|
||||
|
||||
|
||||
# Value of the "purpose" field on StagedContent objects used for clipboards.
|
||||
CLIPBOARD_PURPOSE = "clipboard"
|
||||
# There may be other valid values of "purpose" which aren't defined within this app.
|
||||
|
||||
|
||||
@frozen
|
||||
class StagedContentData:
|
||||
"""
|
||||
Read-only data model representing StagedContent
|
||||
|
||||
(OLX content that isn't part of any course at the moment)
|
||||
"""
|
||||
id: int = field(validator=validators.instance_of(int))
|
||||
user_id: int = field(validator=validators.instance_of(int))
|
||||
created: datetime = field(validator=validators.instance_of(datetime))
|
||||
purpose: str = field(validator=validators.instance_of(str))
|
||||
status: StagedContentStatus = field(validator=validators.in_(StagedContentStatus), converter=StagedContentStatus)
|
||||
block_type: str = field(validator=validators.instance_of(str))
|
||||
display_name: str = field(validator=validators.instance_of(str))
|
||||
|
||||
|
||||
@frozen
|
||||
class UserClipboardData:
|
||||
""" Read-only data model for User Clipboard data (copied OLX) """
|
||||
content: StagedContentData = field(validator=validators.instance_of(StagedContentData))
|
||||
source_usage_key: UsageKey = field(validator=validators.instance_of(UsageKey))
|
||||
@@ -12,6 +12,8 @@ from opaque_keys.edx.keys import LearningContextKey
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
|
||||
|
||||
from .data import CLIPBOARD_PURPOSE, StagedContentStatus
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -32,25 +34,13 @@ class StagedContent(models.Model):
|
||||
class Meta:
|
||||
verbose_name_plural = _("Staged Content")
|
||||
|
||||
class Status(models.TextChoices):
|
||||
""" The status of this staged content. """
|
||||
# LOADING: We are actively (asynchronously) writing the OLX and related data into the staging area.
|
||||
# It is not ready to be read.
|
||||
LOADING = "loading", _("Loading")
|
||||
# READY: The content is staged and ready to be read.
|
||||
READY = "ready", _("Ready")
|
||||
# The content has expired and this row can be deleted, along with any associated data.
|
||||
EXPIRED = "expired", _("Expired")
|
||||
# ERROR: The content could not be staged.
|
||||
ERROR = "error", _("Error")
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
# The user that created and owns this staged content. Only this user can read it.
|
||||
user = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
|
||||
created = models.DateTimeField(null=False, auto_now_add=True)
|
||||
# What this StagedContent is for (e.g. "clipboard" for clipboard)
|
||||
purpose = models.CharField(max_length=64)
|
||||
status = models.CharField(max_length=20, choices=Status.choices)
|
||||
status = models.CharField(max_length=20, choices=StagedContentStatus.choices)
|
||||
|
||||
block_type = models.CharField(
|
||||
max_length=100,
|
||||
@@ -83,9 +73,6 @@ class UserClipboard(models.Model):
|
||||
is some OLX content that can be used in a course, such as an XBlock, a Unit,
|
||||
or a Subsection.
|
||||
"""
|
||||
# value of the "purpose" field on underlying StagedContent objects
|
||||
PURPOSE = "clipboard"
|
||||
|
||||
# The user that copied something. Clipboards are user-specific and
|
||||
# previously copied items are not kept.
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
||||
@@ -114,9 +101,9 @@ class UserClipboard(models.Model):
|
||||
# These could probably be replaced with constraints in Django 4.1+
|
||||
if self.user.id != self.content.user.id:
|
||||
raise ValidationError("User ID mismatch.")
|
||||
if self.content.purpose != UserClipboard.PURPOSE:
|
||||
if self.content.purpose != CLIPBOARD_PURPOSE:
|
||||
raise ValidationError(
|
||||
f"StagedContent.purpose must be '{UserClipboard.PURPOSE}' to use it as clipboard content."
|
||||
f"StagedContent.purpose must be '{CLIPBOARD_PURPOSE}' to use it as clipboard content."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -16,7 +16,7 @@ class StagedContentSerializer(serializers.ModelSerializer):
|
||||
model = StagedContent
|
||||
fields = [
|
||||
'id',
|
||||
'user',
|
||||
'user_id',
|
||||
'created',
|
||||
'purpose',
|
||||
'status',
|
||||
|
||||
@@ -7,7 +7,8 @@ import logging
|
||||
from celery import shared_task
|
||||
from celery_utils.logged_task import LoggedTask
|
||||
|
||||
from .models import StagedContent, UserClipboard
|
||||
from .data import CLIPBOARD_PURPOSE
|
||||
from .models import StagedContent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,5 +22,5 @@ def delete_expired_clipboards(staged_content_ids: list[int]):
|
||||
for pk in staged_content_ids:
|
||||
# Due to signal handlers deleting asset file objects from S3 or similar,
|
||||
# this may be "slow" relative to database speed.
|
||||
StagedContent.objects.get(purpose=UserClipboard.PURPOSE, pk=pk).delete()
|
||||
StagedContent.objects.get(purpose=CLIPBOARD_PURPOSE, pk=pk).delete()
|
||||
log.info(f"Successfully deleted StagedContent entries ({','.join(str(x) for x in staged_content_ids)})")
|
||||
|
||||
@@ -6,12 +6,26 @@ from xml.etree import ElementTree
|
||||
|
||||
from rest_framework.test import APIClient
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from xmodule.modulestore.tests.factories import ToyCourseFactory
|
||||
|
||||
from openedx.core.djangoapps.content_staging import api as python_api
|
||||
|
||||
|
||||
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
|
||||
|
||||
# OLX of the video in the toy course using course_key.make_usage_key("video", "sample_video")
|
||||
SAMPLE_VIDEO_OLX = """
|
||||
<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"
|
||||
/>
|
||||
"""
|
||||
|
||||
|
||||
class ClipboardTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
@@ -22,6 +36,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
When a user has no content on their clipboard, we get an empty 200 response
|
||||
"""
|
||||
## Test the REST API:
|
||||
client = APIClient()
|
||||
client.login(username=self.user.username, password=self.user_password)
|
||||
response = client.get(CLIPBOARD_ENDPOINT)
|
||||
@@ -32,6 +47,13 @@ class ClipboardTestCase(ModuleStoreTestCase):
|
||||
"source_usage_key": "",
|
||||
"source_context_title": ""
|
||||
})
|
||||
## The Python method for getting the API response should be identical:
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
python_api.get_user_clipboard_json(self.user.id, response.wsgi_request),
|
||||
)
|
||||
# And the pure python API should return None
|
||||
self.assertEqual(python_api.get_user_clipboard(self.user.id), None)
|
||||
|
||||
def _setup_course(self):
|
||||
""" Set up the "Toy Course" and an APIClient for testing clipboard functionality. """
|
||||
@@ -49,7 +71,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
|
||||
|
||||
def test_copy_video(self):
|
||||
"""
|
||||
Test copying a video from the course
|
||||
Test copying a video from the course, and retrieve it using the REST API
|
||||
"""
|
||||
course_key, client = self._setup_course()
|
||||
|
||||
@@ -74,24 +96,36 @@ class ClipboardTestCase(ModuleStoreTestCase):
|
||||
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"
|
||||
/>
|
||||
"""
|
||||
)
|
||||
self.assertXmlEqual(olx_response.content.decode(), SAMPLE_VIDEO_OLX)
|
||||
|
||||
# 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_video_python_get(self):
|
||||
"""
|
||||
Test copying a video from the course, and retrieve it using the python API
|
||||
"""
|
||||
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")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Get the clipboard status using python:
|
||||
clipboard_data = python_api.get_user_clipboard(self.user.id)
|
||||
self.assertIsNotNone(clipboard_data)
|
||||
self.assertEqual(clipboard_data.source_usage_key, video_key)
|
||||
# source_context_title is not in the python API because it's easy to retrieve a course's name from python code.
|
||||
self.assertEqual(clipboard_data.content.block_type, "video")
|
||||
# To ensure API stability, we are hard-coding these expected values:
|
||||
self.assertEqual(clipboard_data.content.purpose, "clipboard")
|
||||
self.assertEqual(clipboard_data.content.status, "ready")
|
||||
self.assertEqual(clipboard_data.content.display_name, "default")
|
||||
# Test the actual OLX in the clipboard:
|
||||
olx_data = python_api.get_staged_content_olx(clipboard_data.content.id)
|
||||
self.assertXmlEqual(olx_data, SAMPLE_VIDEO_OLX)
|
||||
|
||||
def test_copy_html(self):
|
||||
"""
|
||||
Test copying an HTML from the course
|
||||
@@ -156,6 +190,8 @@ class ClipboardTestCase(ModuleStoreTestCase):
|
||||
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 Python method for getting the API response should be identical:
|
||||
self.assertEqual(html_clip_data, python_api.get_user_clipboard_json(self.user.id, response.wsgi_request))
|
||||
|
||||
# The OLX link from the video will no longer work:
|
||||
self.assertEqual(client.get(old_olx_url).status_code, 404)
|
||||
@@ -196,4 +232,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
|
||||
|
||||
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))
|
||||
self.assertEqual(
|
||||
ElementTree.canonicalize(xml_str_a, strip_text=True),
|
||||
ElementTree.canonicalize(xml_str_b, strip_text=True),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from xmodule import block_metadata_utils
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .data import CLIPBOARD_PURPOSE, StagedContentStatus
|
||||
from .models import StagedContent, UserClipboard
|
||||
from .serializers import UserClipboardSerializer, PostToClipboardSerializer
|
||||
from .tasks import delete_expired_clipboards
|
||||
@@ -42,7 +43,7 @@ class StagedContentOLXEndpoint(APIView):
|
||||
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 staged_content.status != StagedContentStatus.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.")
|
||||
@@ -117,19 +118,19 @@ class ClipboardEndpoint(APIView):
|
||||
# Mark all of the user's existing StagedContent rows as EXPIRED
|
||||
to_expire = StagedContent.objects.filter(
|
||||
user=request.user,
|
||||
purpose=UserClipboard.PURPOSE,
|
||||
purpose=CLIPBOARD_PURPOSE,
|
||||
).exclude(
|
||||
status=StagedContent.Status.EXPIRED,
|
||||
status=StagedContentStatus.EXPIRED,
|
||||
)
|
||||
for sc in to_expire:
|
||||
expired_ids.append(sc.id)
|
||||
sc.status = StagedContent.Status.EXPIRED
|
||||
sc.status = StagedContentStatus.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,
|
||||
purpose=CLIPBOARD_PURPOSE,
|
||||
status=StagedContentStatus.READY,
|
||||
block_type=usage_key.block_type,
|
||||
olx=block_data.olx_str,
|
||||
display_name=block_metadata_utils.display_name_with_default(block),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
Test for the OLX REST API app.
|
||||
"""
|
||||
import re
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_cms
|
||||
from common.djangoapps.student.roles import CourseStaffRole
|
||||
@@ -40,17 +39,12 @@ class OlxRestApiTestCase(SharedModuleStoreTestCase):
|
||||
|
||||
# Helper methods:
|
||||
|
||||
def assertXmlEqual(self, xml_str_a, xml_str_b):
|
||||
"""
|
||||
Assert that the given XML strings are equal,
|
||||
ignoring attribute order and some whitespace variations.
|
||||
"""
|
||||
def clean(xml_str):
|
||||
# Collapse repeated whitespace:
|
||||
xml_str = re.sub(r'(\s)\s+', r'\1', xml_str)
|
||||
xml_bytes = xml_str.encode('utf8')
|
||||
return minidom.parseString(xml_bytes).toprettyxml()
|
||||
assert clean(xml_str_a) == clean(xml_str_b)
|
||||
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, strip_text=True),
|
||||
ElementTree.canonicalize(xml_str_b, strip_text=True),
|
||||
)
|
||||
|
||||
def get_olx_response_for_block(self, block_id):
|
||||
return self.client.get(f'/api/olx-export/v1/xblock/{block_id}/')
|
||||
|
||||
Reference in New Issue
Block a user