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:
Braden MacDonald
2023-04-27 09:58:04 -07:00
committed by GitHub
parent 08c26aa8ab
commit 8ee1f66ffb
20 changed files with 490 additions and 77 deletions

View File

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

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

View File

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

View File

@@ -16,7 +16,7 @@ class StagedContentSerializer(serializers.ModelSerializer):
model = StagedContent
fields = [
'id',
'user',
'user_id',
'created',
'purpose',
'status',

View File

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

View File

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

View File

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

View File

@@ -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}/')