-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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") | ||
|
@@ -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): | ||
|
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, | ||||||||||||||||||||||||||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
@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), | ||||||||||||||||||||||||||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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__) | ||
|
||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess we should rename this function to just There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
This file was deleted.
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.