Skip to content

Commit

Permalink
feat: basic get/post endpoint for v2 xblocks. TNL-10873
Browse files Browse the repository at this point in the history
  • Loading branch information
Ken Clary committed Jul 20, 2023
1 parent ebd9605 commit d6f824d
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 145 deletions.
1 change: 1 addition & 0 deletions openedx/core/djangoapps/content_libraries/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
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}/'


Expand Down
41 changes: 41 additions & 0 deletions openedx/core/djangoapps/content_libraries/tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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
Expand All @@ -42,6 +43,9 @@ def setUp(self):
self.student_a = UserFactory.create(username="Alice", email="[email protected]", password="edx")
self.student_b = UserFactory.create(username="Bob", email="[email protected]", 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")
Expand Down Expand Up @@ -182,6 +186,43 @@ def test_xblock_metadata(self):
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):
Expand Down
2 changes: 2 additions & 0 deletions openedx/core/djangoapps/xblock/rest_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
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
Expand Down
92 changes: 89 additions & 3 deletions openedx/core/djangoapps/xblock/rest_api/views.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
})
6 changes: 4 additions & 2 deletions openedx/core/djangoapps/xblock/runtime/blockstore_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
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__)

Expand Down Expand Up @@ -133,7 +133,9 @@ def save_block(self, block):
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
Expand Down
139 changes: 0 additions & 139 deletions openedx/core/djangoapps/xblock/runtime/serializer.py

This file was deleted.

3 changes: 2 additions & 1 deletion openedx/core/lib/xblock_serializer/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit d6f824d

Please sign in to comment.