Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: basic get/post endpoint for v2 xblocks. TNL-10873 #32600

Merged
merged 1 commit into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it be an issue for the editors if some newlines go inserted here? This test set the data to '<p>test</p>' but we get the data back as '\n<p>test</p>\n'. I'm assuming that's fine but just want to confirm.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, in fact I don't think editors can have content without the newlines; it's just an artifact of what I wrote in the test.

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.
"""
Comment on lines +179 to +185
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
"""
View for the field values of an XBlock, returned as JSON.
This class mimics the functionality of the v1 xblock_handler in block.py.
However, it only implements the exact subset of functionality needed to support xblock editing, for a single xblock which has no children blocks. As such, it only supports GET and POST.
"""


@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),
kenclary marked this conversation as resolved.
Show resolved Hide resolved
}
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we should rename this function to just serialize_block_for_blockstore since we're already passing in a blockstore block here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is called in a bunch of other places.

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