Merge pull request #32600 from openedx/kenclary/TNL-10873
feat: basic get/post endpoint for v2 xblocks. TNL-10873
This commit is contained in:
@@ -49,6 +49,7 @@ URL_LIB_LTI_LAUNCH = URL_LIB_LTI_PREFIX + 'launch/'
|
||||
URL_BLOCK_RENDER_VIEW = '/api/xblock/v2/xblocks/{block_key}/view/{view_name}/'
|
||||
URL_BLOCK_GET_HANDLER_URL = '/api/xblock/v2/xblocks/{block_key}/handler_url/{handler_name}/'
|
||||
URL_BLOCK_METADATA_URL = '/api/xblock/v2/xblocks/{block_key}/'
|
||||
URL_BLOCK_FIELDS_URL = '/api/xblock/v2/xblocks/{block_key}/fields/'
|
||||
URL_BLOCK_XBLOCK_HANDLER = '/api/xblock/v2/xblocks/{block_key}/handler/{user_id}-{secure_token}/{handler_name}/'
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import (
|
||||
URL_BLOCK_RENDER_VIEW,
|
||||
URL_BLOCK_GET_HANDLER_URL,
|
||||
URL_BLOCK_METADATA_URL,
|
||||
URL_BLOCK_FIELDS_URL,
|
||||
)
|
||||
from openedx.core.djangoapps.content_libraries.tests.user_state_block import UserStateTestBlock
|
||||
from openedx.core.djangoapps.content_libraries.constants import COMPLEX, ALL_RIGHTS_RESERVED, CC_4_BY
|
||||
@@ -42,6 +43,9 @@ class ContentLibraryContentTestMixin:
|
||||
self.student_a = UserFactory.create(username="Alice", email="alice@example.com", password="edx")
|
||||
self.student_b = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
|
||||
|
||||
# staff user
|
||||
self.staff_user = UserFactory(password="edx", is_staff=True)
|
||||
|
||||
# Create a collection using Blockstore API directly only because there
|
||||
# is not yet any Studio REST API for doing so:
|
||||
self.collection = blockstore_api.create_collection("Content Library Test Collection")
|
||||
@@ -182,6 +186,43 @@ class ContentLibraryRuntimeTestMixin(ContentLibraryContentTestMixin):
|
||||
assert metadata_view_result.data['student_view_data'] is None
|
||||
# Capa doesn't provide student_view_data
|
||||
|
||||
@skip_unless_cms # modifying blocks only works properly in Studio
|
||||
def test_xblock_fields(self):
|
||||
"""
|
||||
Test the XBlock fields API
|
||||
"""
|
||||
# act as staff:
|
||||
client = APIClient()
|
||||
client.login(username=self.staff_user.username, password='edx')
|
||||
|
||||
# create/save a block using the library APIs first
|
||||
unit_block_key = library_api.create_library_block(self.library.key, "unit", "fields-u1").usage_key
|
||||
block_key = library_api.create_library_block_child(unit_block_key, "html", "fields-p1").usage_key
|
||||
new_olx = """
|
||||
<html display_name="New Text Block">
|
||||
<p>This is some <strong>HTML</strong>.</p>
|
||||
</html>
|
||||
""".strip()
|
||||
library_api.set_library_block_olx(block_key, new_olx)
|
||||
library_api.publish_changes(self.library.key)
|
||||
|
||||
# Check the GET API for the block:
|
||||
fields_get_result = client.get(URL_BLOCK_FIELDS_URL.format(block_key=block_key))
|
||||
assert fields_get_result.data['display_name'] == 'New Text Block'
|
||||
assert fields_get_result.data['data'].strip() == '<p>This is some <strong>HTML</strong>.</p>'
|
||||
assert fields_get_result.data['metadata']['display_name'] == 'New Text Block'
|
||||
|
||||
# Check the POST API for the block:
|
||||
fields_post_result = client.post(URL_BLOCK_FIELDS_URL.format(block_key=block_key), data={
|
||||
'data': '<p>test</p>',
|
||||
'metadata': {
|
||||
'display_name': 'New Display Name',
|
||||
}
|
||||
}, format='json')
|
||||
block_saved = xblock_api.load_block(block_key, self.staff_user)
|
||||
assert block_saved.data == '\n<p>test</p>\n'
|
||||
assert xblock_api.get_block_display_name(block_saved) == 'New Display Name'
|
||||
|
||||
|
||||
@requires_blockstore
|
||||
class ContentLibraryRuntimeBServiceTest(ContentLibraryRuntimeTestMixin, TestCase):
|
||||
|
||||
@@ -15,6 +15,8 @@ urlpatterns = [
|
||||
path('xblocks/<str:usage_key_str>/', include([
|
||||
# get metadata about an XBlock:
|
||||
path('', views.block_metadata),
|
||||
# get/post full json fields of an XBlock:
|
||||
path('fields/', views.BlockFieldsView.as_view()),
|
||||
# render one of this XBlock's views (e.g. student_view)
|
||||
re_path(r'^view/(?P<view_name>[\w\-]+)/$', views.render_block_view),
|
||||
# get the URL needed to call this XBlock's handlers
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
"""
|
||||
Views that implement a RESTful API for interacting with XBlocks.
|
||||
|
||||
Note that these views are only for interacting with existing blocks. Other
|
||||
Studio APIs cover use cases like adding/deleting/editing blocks.
|
||||
"""
|
||||
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
from corsheaders.signals import check_request_enabled
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.transaction import atomic
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework import permissions
|
||||
from rest_framework.decorators import api_view, permission_classes # lint-amnesty, pylint: disable=unused-import
|
||||
from rest_framework.exceptions import PermissionDenied, AuthenticationFailed, NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from xblock.django.request import DjangoWebobRequest, webob_to_django_response
|
||||
from xblock.fields import Scope
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from ..api import (
|
||||
get_block_display_name,
|
||||
get_block_metadata,
|
||||
get_handler_url as _get_handler_url,
|
||||
load_block,
|
||||
@@ -168,3 +171,86 @@ def cors_allow_xblock_handler(sender, request, **kwargs): # lint-amnesty, pylin
|
||||
|
||||
|
||||
check_request_enabled.connect(cors_allow_xblock_handler)
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class BlockFieldsView(APIView):
|
||||
"""
|
||||
View to get/edit the field values of an XBlock as JSON (in the v2 runtime)
|
||||
|
||||
This class mimics the functionality of xblock_handler in block.py (for v1 xblocks), but for v2 xblocks.
|
||||
However, it only implements the exact subset of functionality needed to support the v2 editors (from
|
||||
the frontend-lib-content-components project). As such, it only supports GET and POST, and only the
|
||||
POSTing of data/metadata fields.
|
||||
"""
|
||||
|
||||
@atomic
|
||||
def get(self, request, usage_key_str):
|
||||
"""
|
||||
retrieves the xblock, returning display_name, data, and metadata
|
||||
"""
|
||||
try:
|
||||
usage_key = UsageKey.from_string(usage_key_str)
|
||||
except InvalidKeyError as e:
|
||||
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
|
||||
|
||||
block = load_block(usage_key, request.user)
|
||||
block_dict = {
|
||||
"display_name": get_block_display_name(block), # potentially duplicated from metadata
|
||||
"data": block.data,
|
||||
"metadata": block.get_explicitly_set_fields_by_scope(Scope.settings),
|
||||
}
|
||||
return Response(block_dict)
|
||||
|
||||
@atomic
|
||||
def post(self, request, usage_key_str):
|
||||
"""
|
||||
edits the xblock, saving changes to data and metadata only (display_name included in metadata)
|
||||
"""
|
||||
try:
|
||||
usage_key = UsageKey.from_string(usage_key_str)
|
||||
except InvalidKeyError as e:
|
||||
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
|
||||
|
||||
user = request.user
|
||||
block = load_block(usage_key, user)
|
||||
data = request.data.get("data")
|
||||
metadata = request.data.get("metadata")
|
||||
|
||||
old_metadata = block.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
old_content = block.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
|
||||
block.data = data
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'.
|
||||
if metadata is not None:
|
||||
for metadata_key, value in metadata.items():
|
||||
field = block.fields[metadata_key]
|
||||
|
||||
if value is None:
|
||||
field.delete_from(block)
|
||||
else:
|
||||
try:
|
||||
value = field.from_json(value)
|
||||
except ValueError as verr:
|
||||
reason = _("Invalid data")
|
||||
if str(verr):
|
||||
reason = _("Invalid data ({details})").format(
|
||||
details=str(verr)
|
||||
)
|
||||
return JsonResponse({"error": reason}, 400)
|
||||
|
||||
field.write_to(block, value)
|
||||
|
||||
if callable(getattr(block, "editor_saved", None)):
|
||||
block.editor_saved(user, old_metadata, old_content)
|
||||
|
||||
# Save after the callback so any changes made in the callback will get persisted.
|
||||
block.save()
|
||||
|
||||
return Response({
|
||||
"id": str(block.location),
|
||||
"data": data,
|
||||
"metadata": block.get_explicitly_set_fields_by_scope(Scope.settings),
|
||||
})
|
||||
|
||||
@@ -14,13 +14,13 @@ from xblock.fields import ScopeIds
|
||||
from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
|
||||
from openedx.core.djangoapps.xblock.runtime.runtime import XBlockRuntime
|
||||
from openedx.core.djangoapps.xblock.runtime.olx_parsing import parse_xblock_include, BundleFormatException
|
||||
from openedx.core.djangoapps.xblock.runtime.serializer import serialize_xblock
|
||||
from openedx.core.djangolib.blockstore_cache import (
|
||||
BundleCache,
|
||||
get_bundle_file_data_with_cache,
|
||||
get_bundle_file_metadata_with_cache,
|
||||
)
|
||||
from openedx.core.lib import blockstore_api
|
||||
from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_blockstore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -133,7 +133,9 @@ class BlockstoreXBlockRuntime(XBlockRuntime):
|
||||
if not learning_context.can_edit_block(self.user, block.scope_ids.usage_id):
|
||||
log.warning("User %s does not have permission to edit %s", self.user.username, block.scope_ids.usage_id)
|
||||
raise RuntimeError("You do not have permission to edit this XBlock")
|
||||
olx_str, static_files = serialize_xblock(block)
|
||||
serialized = serialize_modulestore_block_for_blockstore(block)
|
||||
olx_str = serialized.olx_str
|
||||
static_files = serialized.static_files
|
||||
# Write the OLX file to the bundle:
|
||||
draft_uuid = blockstore_api.get_or_create_bundle_draft(
|
||||
definition_key.bundle_uuid, definition_key.draft_name
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
Code to serialize an XBlock to OLX
|
||||
"""
|
||||
|
||||
from collections import namedtuple
|
||||
from contextlib import contextmanager
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fs.memoryfs import MemoryFS
|
||||
from fs.wrapfs import WrapFS
|
||||
from lxml.etree import Element
|
||||
from lxml.etree import tostring as etree_tostring
|
||||
|
||||
from xmodule.xml_block import XmlMixin
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A static file required by an XBlock
|
||||
StaticFile = namedtuple('StaticFile', ['name', 'data'])
|
||||
|
||||
|
||||
def serialize_xblock(block):
|
||||
"""
|
||||
Given an XBlock instance, serialize it to OLX
|
||||
|
||||
Returns
|
||||
(olx_str, static_files)
|
||||
where olx_str is the XML as a string, and static_files is a list of
|
||||
StaticFile objects for any small data files that the XBlock may need for
|
||||
complete serialization (e.g. video subtitle files or a .html data file for
|
||||
an HTML block).
|
||||
"""
|
||||
static_files = []
|
||||
# Create an XML node to hold the exported data
|
||||
olx_node = 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 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/module 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()
|
||||
static_files.append(StaticFile(name=unit_file.name, data=data))
|
||||
|
||||
# Remove 'url_name' - we store the definition key in the folder name
|
||||
# that holds the OLX and the usage key elsewhere, so specifying it
|
||||
# within the OLX file is redundant and can lead to issues if the file is
|
||||
# copied and pasted elsewhere in the bundle with a new definition key.
|
||||
olx_node.attrib.pop('url_name', None)
|
||||
|
||||
# Add <xblock-include /> tags for each child:
|
||||
if block.has_children and block.children:
|
||||
try:
|
||||
child_includes = block.runtime.child_includes_of(block)
|
||||
except AttributeError:
|
||||
raise RuntimeError("Cannot get child includes of block. Make sure it's using BlockstoreXBlockRuntime") # lint-amnesty, pylint: disable=raise-missing-from
|
||||
if len(child_includes) != len(block.children):
|
||||
raise RuntimeError(
|
||||
"Mistmatch between block.children and runtime.child_includes_of()."
|
||||
"Make sure the block was loaded via runtime.get_block() and that "
|
||||
"the block.children field was not modified directly (use "
|
||||
"block.runtime.add_child_include() instead)."
|
||||
)
|
||||
for include_data in child_includes:
|
||||
definition_str = include_data.block_type + "/" + include_data.definition_id
|
||||
attrs = {"definition": definition_str}
|
||||
if include_data.usage_hint:
|
||||
attrs["usage"] = include_data.usage_hint
|
||||
if include_data.link_id:
|
||||
attrs["source"] = include_data.link_id
|
||||
olx_node.append(olx_node.makeelement("xblock-include", attrs))
|
||||
|
||||
# Serialize the resulting XML to a string:
|
||||
olx_str = etree_tostring(olx_node, encoding="utf-8", pretty_print=True)
|
||||
return (olx_str, static_files)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def override_export_fs(block):
|
||||
"""
|
||||
Hack that makes some legacy XBlocks which inherit `XmlMixin.add_xml_to_node`
|
||||
instead of the usual `XmlSerialization.add_xml_to_node` serializable to a string.
|
||||
This is needed for the OLX export API.
|
||||
|
||||
Originally, `add_xml_to_node` was `XModuleDescriptor`'s method and was migrated to `XmlMixin`
|
||||
as part of the content core platform refactoring. It differs from `XmlSerialization.add_xml_to_node`
|
||||
in that it relies on `XmlMixin.export_to_file` (or `CustomTagBlock.export_to_file`) method to control
|
||||
whether a block has to be exported as two files (one .olx pointing to one .xml) file, or a single XML node.
|
||||
|
||||
For the legacy blocks (`AnnotatableBlock` for instance) `export_to_file` returns `True` by default.
|
||||
The only exception is `CustomTagBlock`, for which this method was originally developed, as customtags don't
|
||||
have to be exported as separate files.
|
||||
|
||||
This method temporarily replaces a block's runtime's `export_fs` system with an in-memory filesystem.
|
||||
Also, it abuses the `XmlMixin.export_to_file` API to prevent the XBlock export code from exporting
|
||||
each block as two files (one .olx pointing to one .xml file).
|
||||
|
||||
Although `XModuleDescriptor` has been removed a long time ago, we have to keep this hack untill the legacy
|
||||
`add_xml_to_node` implementation is removed in favor of `XmlSerialization.add_xml_to_node`, which itself
|
||||
is a hard task involving refactoring of `CourseExportManager`.
|
||||
"""
|
||||
fs = WrapFS(MemoryFS())
|
||||
fs.makedir('course')
|
||||
fs.makedir('course/static') # Video XBlock requires this directory to exists, to put srt files etc.
|
||||
|
||||
old_export_fs = block.runtime.export_fs
|
||||
block.runtime.export_fs = fs
|
||||
if hasattr(block, 'export_to_file'):
|
||||
old_export_to_file = block.export_to_file
|
||||
block.export_to_file = lambda: False
|
||||
old_global_export_to_file = XmlMixin.export_to_file
|
||||
XmlMixin.export_to_file = lambda _: False # So this applies to child blocks that get loaded during export
|
||||
try:
|
||||
yield fs
|
||||
except: # lint-amnesty, pylint: disable=try-except-raise
|
||||
raise
|
||||
finally:
|
||||
block.runtime.export_fs = old_export_fs
|
||||
if hasattr(block, 'export_to_file'):
|
||||
block.export_to_file = old_export_to_file
|
||||
XmlMixin.export_to_file = old_global_export_to_file
|
||||
@@ -45,7 +45,8 @@ def rewrite_absolute_static_urls(text, course_id):
|
||||
/static/SCI_1.2_Image_.png
|
||||
format for consistency and portability.
|
||||
"""
|
||||
assert isinstance(course_id, CourseKey)
|
||||
if not course_id.is_course:
|
||||
return text # We can't rewrite URLs for libraries, which don't have "Files & Uploads".
|
||||
asset_full_url_re = r'https?://[^/]+/(?P<maybe_asset_key>[^\s\'"&]+)'
|
||||
|
||||
def check_asset_key(match_obj):
|
||||
|
||||
Reference in New Issue
Block a user