feat: Allow specifying a version when loading a v2 XBlock (#35626)

This commit is contained in:
Braden MacDonald
2024-10-16 08:43:20 -07:00
committed by GitHub
parent 77e683d5b6
commit 4ca522152f
14 changed files with 479 additions and 64 deletions

View File

@@ -743,13 +743,15 @@ def get_library_block(usage_key, include_collections=False) -> LibraryXBlockMeta
return xblock_metadata
def set_library_block_olx(usage_key, new_olx_str):
def set_library_block_olx(usage_key, new_olx_str) -> int:
"""
Replace the OLX source of the given XBlock.
This is only meant for use by developers or API client applications, as
very little validation is done and this can easily result in a broken XBlock
that won't load.
Returns the version number of the newly created ComponentVersion.
"""
# because this old pylint can't understand attr.ib() objects, pylint: disable=no-member
assert isinstance(usage_key, LibraryUsageLocatorV2)
@@ -786,7 +788,7 @@ def set_library_block_olx(usage_key, new_olx_str):
text=new_olx_str,
created=now,
)
authoring_api.create_next_version(
new_component_version = authoring_api.create_next_component_version(
component.pk,
title=new_title,
content_to_replace={
@@ -802,6 +804,8 @@ def set_library_block_olx(usage_key, new_olx_str):
)
)
return new_component_version.version_num
def library_component_usage_key(
library_key: LibraryLocatorV2,

View File

@@ -217,6 +217,7 @@ class LibraryXBlockOlxSerializer(serializers.Serializer):
Serializer for representing an XBlock's OLX
"""
olx = serializers.CharField()
version_num = serializers.IntegerField(read_only=True, required=False)
class LibraryXBlockStaticFileSerializer(serializers.Serializer):

View File

@@ -36,6 +36,7 @@ URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
URL_LIB_LTI_LAUNCH = URL_LIB_LTI_PREFIX + 'launch/'
URL_BLOCK_RENDER_VIEW = '/api/xblock/v2/xblocks/{block_key}/view/{view_name}/'
URL_BLOCK_EMBED_VIEW = '/xblocks/v2/{block_key}/embed/{view_name}/' # Returns HTML not JSON so its URL is different
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/'
@@ -300,6 +301,24 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
url = URL_BLOCK_RENDER_VIEW.format(block_key=block_key, view_name=view_name)
return self._api('get', url, None, expect_response)
def _embed_block(
self,
block_key,
*,
view_name="student_view",
version: str | int | None = None,
expect_response=200,
) -> str:
"""
Get an HTML response that displays the given XBlock. Returns HTML.
"""
url = URL_BLOCK_EMBED_VIEW.format(block_key=block_key, view_name=view_name)
if version is not None:
url += f"?version={version}"
response = self.client.get(url)
assert response.status_code == expect_response, 'Unexpected response code {}:'.format(response.status_code)
return response.content.decode()
def _get_block_handler_url(self, block_key, handler_name):
"""
Get the URL to call a specific XBlock's handler.

View File

@@ -0,0 +1,60 @@
"""
Block for testing variously scoped XBlock fields.
"""
import json
from webob import Response
from web_fragments.fragment import Fragment
from xblock.core import XBlock, Scope
from xblock import fields
class FieldsTestBlock(XBlock):
"""
Block for testing variously scoped XBlock fields and XBlock handlers.
This has only authored fields. See also UserStateTestBlock which has user fields.
"""
BLOCK_TYPE = "fields-test"
has_score = False
display_name = fields.String(scope=Scope.settings, name='User State Test Block')
setting_field = fields.String(scope=Scope.settings, name='A setting')
content_field = fields.String(scope=Scope.content, name='A setting')
@XBlock.json_handler
def update_fields(self, data, suffix=None): # pylint: disable=unused-argument
"""
Update the authored fields of this block
"""
self.display_name = data["display_name"]
self.setting_field = data["setting_field"]
self.content_field = data["content_field"]
return {}
@XBlock.handler
def get_fields(self, request, suffix=None): # pylint: disable=unused-argument
"""
Get the various fields of this XBlock.
"""
return Response(
json.dumps({
"display_name": self.display_name,
"setting_field": self.setting_field,
"content_field": self.content_field,
}),
content_type='application/json',
charset='UTF-8',
)
def student_view(self, _context):
"""
Return the student view.
"""
fragment = Fragment()
fragment.add_content(f'<h1>{self.display_name}</h1>\n')
fragment.add_content(f'<p>SF: {self.setting_field}</p>\n')
fragment.add_content(f'<p>CF: {self.content_field}</p>\n')
handler_url = self.runtime.handler_url(self, 'get_fields')
fragment.add_content(f'<p>handler URL: {handler_url}</p>\n')
return fragment

View File

@@ -0,0 +1,184 @@
"""
Tests for the XBlock v2 runtime's "embed" view, using Content Libraries
This view is used in the MFE to preview XBlocks that are in the library.
"""
import re
import ddt
from django.core.exceptions import ValidationError
from django.test.utils import override_settings
from openedx_events.tests.utils import OpenEdxEventsTestMixin
import pytest
from xblock.core import XBlock
from openedx.core.djangoapps.content_libraries.tests.base import (
ContentLibrariesRestApiTest
)
from openedx.core.djangolib.testing.utils import skip_unless_cms
from .fields_test_block import FieldsTestBlock
@skip_unless_cms
@ddt.ddt
@override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment?
class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin):
"""
Tests for embed_view and interacting with draft/published/past versions of
Learning-Core-based XBlocks (in Content Libraries).
These tests use the REST API, which in turn relies on the Python API.
Some tests may use the python API directly if necessary to provide
coverage of any code paths not accessible via the REST API.
In general, these tests should
(1) Use public APIs only - don't directly create data using other methods,
which results in a less realistic test and ties the test suite too
closely to specific implementation details.
(Exception: users can be provisioned using a user factory)
(2) Assert that fields are present in responses, but don't assert that the
entire response has some specific shape. That way, things like adding
new fields to an API response, which are backwards compatible, won't
break any tests, but backwards-incompatible API changes will.
WARNING: every test should have a unique library slug, because even though
the django/mysql database gets reset for each test case, the lookup between
library slug and bundle UUID does not because it's assumed to be immutable
and cached forever.
"""
@XBlock.register_temp_plugin(FieldsTestBlock, FieldsTestBlock.BLOCK_TYPE)
def test_embed_vew_versions(self):
"""
Test that the embed_view renders a block and can render different versions of it.
"""
# Create a library:
lib = self._create_library(slug="test-eb-1", title="Test Library", description="")
lib_id = lib["id"]
# Create an XBlock. This will be the empty version 1:
create_response = self._add_block_to_library(lib_id, FieldsTestBlock.BLOCK_TYPE, "block1")
block_id = create_response["id"]
# Create version 2 of the block by setting its OLX:
olx_response = self._set_library_block_olx(block_id, """
<fields-test
display_name="Field Test Block (Old, v2)"
setting_field="Old setting value 2."
content_field="Old content value 2."
/>
""")
assert olx_response["version_num"] == 2
# Create version 3 of the block by setting its OLX again:
olx_response = self._set_library_block_olx(block_id, """
<fields-test
display_name="Field Test Block (Published, v3)"
setting_field="Published setting value 3."
content_field="Published content value 3."
/>
""")
assert olx_response["version_num"] == 3
# Publish the library:
self._commit_library_changes(lib_id)
# Create the draft (version 4) of the block:
olx_response = self._set_library_block_olx(block_id, """
<fields-test
display_name="Field Test Block (Draft, v4)"
setting_field="Draft setting value 4."
content_field="Draft content value 4."
/>
""")
# Now render the "embed block" view. This test only runs in CMS so it should default to the draft:
html = self._embed_block(block_id)
def check_fields(display_name, setting_value, content_value):
assert f'<h1>{display_name}</h1>' in html
assert f'<p>SF: {setting_value}</p>' in html
assert f'<p>CF: {content_value}</p>' in html
handler_url = re.search(r'<p>handler URL: ([^<]+)</p>', html).group(1)
assert handler_url.startswith('http')
handler_result = self.client.get(handler_url).json()
assert handler_result == {
"display_name": display_name,
"setting_field": setting_value,
"content_field": content_value,
}
check_fields('Field Test Block (Draft, v4)', 'Draft setting value 4.', 'Draft content value 4.')
# But if we request the published version, we get that:
html = self._embed_block(block_id, version="published")
check_fields('Field Test Block (Published, v3)', 'Published setting value 3.', 'Published content value 3.')
# And if we request a specific version, we get that:
html = self._embed_block(block_id, version=3)
check_fields('Field Test Block (Published, v3)', 'Published setting value 3.', 'Published content value 3.')
# And if we request a specific version, we get that:
html = self._embed_block(block_id, version=2)
check_fields('Field Test Block (Old, v2)', 'Old setting value 2.', 'Old content value 2.')
html = self._embed_block(block_id, version=4)
check_fields('Field Test Block (Draft, v4)', 'Draft setting value 4.', 'Draft content value 4.')
@XBlock.register_temp_plugin(FieldsTestBlock, FieldsTestBlock.BLOCK_TYPE)
def test_handlers_modifying_published_data(self):
"""
Test that if we requested any version other than "draft", the handlers should not allow _writing_ to authored
field data (because you'd be overwriting the latest draft version with changes based on an old version).
We may decide to relax this restriction in the future. Not sure how important it is.
Writing to student state is OK.
"""
# Create a library:
lib = self._create_library(slug="test-eb-2", title="Test Library", description="")
lib_id = lib["id"]
# Create an XBlock. This will be the empty version 1:
create_response = self._add_block_to_library(lib_id, FieldsTestBlock.BLOCK_TYPE, "block1")
block_id = create_response["id"]
# Now render the "embed block" view. This test only runs in CMS so it should default to the draft:
html = self._embed_block(block_id)
def call_update_handler(**kwargs):
handler_url = re.search(r'<p>handler URL: ([^<]+)</p>', html).group(1)
assert handler_url.startswith('http')
handler_url = handler_url.replace('get_fields', 'update_fields')
response = self.client.post(handler_url, kwargs, format='json')
assert response.status_code == 200
def check_fields(display_name, setting_field, content_field):
assert f'<h1>{display_name}</h1>' in html
assert f'<p>SF: {setting_field}</p>' in html
assert f'<p>CF: {content_field}</p>' in html
# Call the update handler to change the fields on the draft:
call_update_handler(display_name="DN-01", setting_field="SV-01", content_field="CV-01")
# Render the block again and check that the handler was able to update the fields:
html = self._embed_block(block_id)
check_fields(display_name="DN-01", setting_field="SV-01", content_field="CV-01")
# Publish the library:
self._commit_library_changes(lib_id)
# Now try changing the authored fields of the published version using a handler:
html = self._embed_block(block_id, version="published")
expected_msg = "Do not make changes to a component starting from the published or past versions."
with pytest.raises(ValidationError, match=expected_msg) as err:
call_update_handler(display_name="DN-X", setting_field="SV-X", content_field="CV-X")
# Now try changing the authored fields of a specific past version using a handler:
html = self._embed_block(block_id, version=2)
with pytest.raises(ValidationError, match=expected_msg) as err:
call_update_handler(display_name="DN-X", setting_field="SV-X", content_field="CV-X")
# Make sure the fields were not updated:
html = self._embed_block(block_id)
check_fields(display_name="DN-01", setting_field="SV-01", content_field="CV-01")
# TODO: test that any static assets referenced in the student_view html are loaded as the correct version, and not
# always loaded as "latest draft".
# TODO: if we are ever able to run these tests in the LMS, test that the LMS only allows accessing the published
# version.

View File

@@ -731,10 +731,10 @@ class LibraryBlockOlxView(APIView):
serializer.is_valid(raise_exception=True)
new_olx_str = serializer.validated_data["olx"]
try:
api.set_library_block_olx(key, new_olx_str)
version_num = api.set_library_block_olx(key, new_olx_str)
except ValueError as err:
raise ValidationError(detail=str(err)) # lint-amnesty, pylint: disable=raise-missing-from
return Response(LibraryXBlockOlxSerializer({"olx": new_olx_str}).data)
return Response(LibraryXBlockOlxSerializer({"olx": new_olx_str, "version_num": version_num}).data)
@method_decorator(non_atomic_requests, name="dispatch")

View File

@@ -5,7 +5,7 @@ we keep here some extra classes for usage within edx-platform. These classes cov
import logging
from edx_toggles.toggles import WaffleFlag
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.keys import CourseKey, LearningContextKey
log = logging.getLogger(__name__)
@@ -99,7 +99,11 @@ class CourseWaffleFlag(WaffleFlag):
def is_enabled(self, course_key=None): # pylint: disable=arguments-differ
"""
Returns whether or not the flag is enabled within the context of a given course.
Returns whether or not the flag is enabled within the context of a given
course.
Can also be given the key of any other learning context (like a content
library), but it will act like a regular waffle flag in that case.
Arguments:
course_key (Optional[CourseKey]): The course to check for override before
@@ -107,12 +111,12 @@ class CourseWaffleFlag(WaffleFlag):
outside the context of any course.
"""
if course_key:
assert isinstance(
course_key, CourseKey
), "Provided course_key '{}' is not instance of CourseKey.".format(
course_key
)
is_enabled_for_course = self._get_course_override_value(course_key)
if is_enabled_for_course is not None:
return is_enabled_for_course
if isinstance(course_key, CourseKey):
is_enabled_for_course = self._get_course_override_value(course_key)
if is_enabled_for_course is not None:
return is_enabled_for_course
else:
# In case this gets called with a content library key, that's fine - just ignore it and
# act like a normal waffle flag. We currently don't support library-specific overrides.
assert isinstance(course_key, LearningContextKey), "expected a course key or other learning context key"
return super().is_enabled()

View File

@@ -17,7 +17,7 @@ from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.translation import gettext as _
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import Component
from openedx_learning.api.authoring_models import Component, ComponentVersion
from opaque_keys.edx.keys import UsageKeyV2
from opaque_keys.edx.locator import BundleDefinitionLocator, LibraryUsageLocatorV2
from rest_framework.exceptions import NotFound
@@ -32,6 +32,7 @@ from openedx.core.djangoapps.xblock.runtime.learning_core_runtime import (
LearningCoreFieldData,
LearningCoreXBlockRuntime,
)
from .data import CheckPerm, LatestVersion
from .utils import get_secure_token_for_xblock_handler, get_xblock_id_for_anonymous_user
from .runtime.learning_core_runtime import LearningCoreXBlockRuntime
@@ -44,16 +45,6 @@ from openedx.core.djangoapps.xblock.learning_context import LearningContext
log = logging.getLogger(__name__)
class CheckPerm(Enum):
""" Options for the default permission check done by load_block() """
# can view the published block and call handlers etc. but not necessarily view its OLX source nor field data
CAN_LEARN = 1
# read-only studio view: can see the block (draft or published), see its OLX, see its field data, etc.
CAN_READ_AS_AUTHOR = 2
# can view everything and make changes to the block
CAN_EDIT = 3
def get_runtime(user: UserType):
"""
Return a new XBlockRuntime.
@@ -73,7 +64,13 @@ def get_runtime(user: UserType):
return runtime
def load_block(usage_key, user, *, check_permission: CheckPerm | None = CheckPerm.CAN_LEARN):
def load_block(
usage_key: UsageKeyV2,
user: UserType,
*,
check_permission: CheckPerm | None = CheckPerm.CAN_LEARN,
version: int | LatestVersion = LatestVersion.AUTO,
):
"""
Load the specified XBlock for the given user.
@@ -112,10 +109,13 @@ def load_block(usage_key, user, *, check_permission: CheckPerm | None = CheckPer
runtime = get_runtime(user=user)
try:
return runtime.get_block(usage_key)
return runtime.get_block(usage_key, version=version)
except NoSuchUsage as exc:
# Convert NoSuchUsage to NotFound so we do the right thing (404 not 500) by default.
raise NotFound(f"The component '{usage_key}' does not exist.") from exc
except ComponentVersion.DoesNotExist as exc:
# Convert ComponentVersion.DoesNotExist to NotFound so we do the right thing (404 not 500) by default.
raise NotFound(f"The requested version of component '{usage_key}' does not exist.") from exc
def get_block_metadata(block, includes=()):
@@ -248,7 +248,13 @@ def render_block_view(block, view_name, user): # pylint: disable=unused-argumen
return fragment
def get_handler_url(usage_key, handler_name, user):
def get_handler_url(
usage_key: UsageKeyV2,
handler_name: str,
user: UserType | None,
*,
version: int | LatestVersion = LatestVersion.AUTO,
):
"""
A method for getting the URL to any XBlock handler. The URL must be usable
without any authentication (no cookie, no OAuth/JWT), and may expire. (So
@@ -265,14 +271,19 @@ def get_handler_url(usage_key, handler_name, user):
usage_key - Usage Key (Opaque Key object or string)
handler_name - Name of the handler or a dummy name like 'any_handler'
user - Django User (registered or anonymous)
version - Run the handler against a specific version of the
block (e.g. when viewing an old version of it in
Studio). Some blocks use handlers to load their data
so it's important the handler matches the student_view
etc.
This view does not check/care if the XBlock actually exists.
"""
usage_key_str = str(usage_key)
site_root_url = get_xblock_app_config().get_site_root_url()
if not user: # lint-amnesty, pylint: disable=no-else-raise
if not user:
raise TypeError("Cannot get handler URLs without specifying a specific user ID.")
elif user.is_authenticated:
if user.is_authenticated:
user_id = user.id
elif user.is_anonymous:
user_id = get_xblock_id_for_anonymous_user(user)
@@ -282,12 +293,16 @@ def get_handler_url(usage_key, handler_name, user):
# and this XBlock:
secure_token = get_secure_token_for_xblock_handler(user_id, usage_key_str)
# Now generate the URL to that handler:
path = reverse('xblock_api:xblock_handler', kwargs={
kwargs = {
'usage_key_str': usage_key_str,
'user_id': user_id,
'secure_token': secure_token,
'handler_name': handler_name,
})
}
path = reverse('xblock_api:xblock_handler', kwargs=kwargs)
if version != LatestVersion.AUTO:
path += "?version=" + (str(version) if isinstance(version, int) else version.value)
# We must return an absolute URL. We can't just use
# rest_framework.reverse.reverse to get the absolute URL because this method
# can be called by the XBlock from python as well and in that case we don't

View File

@@ -5,7 +5,7 @@ from django.apps import AppConfig, apps
from django.conf import settings
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from .data import StudentDataMode
from .data import StudentDataMode, AuthoredDataMode
class XBlockAppConfig(AppConfig):
@@ -50,6 +50,7 @@ class LmsXBlockAppConfig(XBlockAppConfig):
"""
return dict(
student_data_mode=StudentDataMode.Persisted,
authored_data_mode=AuthoredDataMode.STRICTLY_PUBLISHED,
)
def get_site_root_url(self):
@@ -72,6 +73,7 @@ class StudioXBlockAppConfig(XBlockAppConfig):
"""
return dict(
student_data_mode=StudentDataMode.Ephemeral,
authored_data_mode=AuthoredDataMode.DEFAULT_DRAFT,
)
def get_site_root_url(self):

View File

@@ -11,3 +11,39 @@ class StudentDataMode(Enum):
"""
Ephemeral = 'ephemeral'
Persisted = 'persisted'
class AuthoredDataMode(Enum):
"""
Runtime configuration which determines whether published or draft versions of content is used by default.
"""
# Published only: used by the LMS. ONLY the published version of an XBlock is ever loaded. Users/APIs cannot request
# the draft version nor a specific version.
STRICTLY_PUBLISHED = 'published'
# Default draft: used by Studio. By default the "lastest draft" version of an XBlock is used, but users/APIs can
# also request to see the published version or any specific (old) version.
DEFAULT_DRAFT = 'persisted'
class CheckPerm(Enum):
"""
Options for the default permission check done by load_block()
"""
# can view the published block and call handlers etc. but not necessarily view its OLX source nor field data
CAN_LEARN = 1
# read-only studio view: can see the block (draft or published), see its OLX, see its field data, etc.
CAN_READ_AS_AUTHOR = 2
# can view everything and make changes to the block
CAN_EDIT = 3
class LatestVersion(Enum):
"""
Options for specifying which version of an XBlock you want to load, if not specifying a specific version.
"""
# Get the latest draft
DRAFT = "draft"
# Get the latest published version
PUBLISHED = "published"
# Get the default (based on AuthoredDataMode, i.e. published for LMS APIs, draft for Studio APIs)
AUTO = "auto"

View File

@@ -32,6 +32,6 @@ urlpatterns = [
path('xblocks/v2/<str:usage_key_str>/', include([
# render one of this XBlock's views (e.g. student_view) for embedding in an iframe
# NOTE: this endpoint is **unstable** and subject to changes after Sumac
re_path(r'^embed/(?P<view_name>[\w\-]+)/$', views.embed_block_view),
path('embed/<str:view_name>/', views.embed_block_view),
])),
]

View File

@@ -1,7 +1,6 @@
"""
Views that implement a RESTful API for interacting with XBlocks.
"""
import itertools
import json
from common.djangoapps.util.json_request import JsonResponse
@@ -14,7 +13,7 @@ from django.shortcuts import render
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 import permissions, serializers
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
@@ -30,6 +29,7 @@ from openedx.core.djangoapps.xblock.learning_context.manager import get_learning
from openedx.core.lib.api.view_utils import view_auth_classes
from ..api import (
CheckPerm,
LatestVersion,
get_block_metadata,
get_block_display_name,
get_handler_url as _get_handler_url,
@@ -43,6 +43,25 @@ User = get_user_model()
invalid_not_found_fmt = "XBlock {usage_key} does not exist, or you don't have permission to view it."
def parse_version_request(version_str: str | None) -> LatestVersion | int:
"""
Given a version parameter from a query string (?version=14, ?version=draft,
?version=published), get the LatestVersion parameter to use with the API.
"""
if version_str is None:
return LatestVersion.AUTO # AUTO = published if we're in the LMS, draft if we're in Studio.
if version_str == "draft":
return LatestVersion.DRAFT
if version_str == "published":
return LatestVersion.PUBLISHED
try:
return int(version_str)
except ValueError:
raise serializers.ValidationError( # pylint: disable=raise-missing-from
"Invalid version specifier '{version_str}'. Expected 'draft', 'published', or an integer."
)
@api_view(['GET'])
@view_auth_classes(is_authenticated=False)
@permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context
@@ -108,16 +127,23 @@ def embed_block_view(request, usage_key_str, view_name):
except InvalidKeyError as e:
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
# Check if a specific version has been requested
version = parse_version_request(request.GET.get("version"))
try:
block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_LEARN)
block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_LEARN, version=version)
except NoSuchUsage as exc:
raise NotFound(f"{usage_key} not found") from exc
fragment = _render_block_view(block, view_name, request.user)
handler_urls = {
str(key): _get_handler_url(key, 'handler_name', request.user)
for key in itertools.chain([block.scope_ids.usage_id], getattr(block, 'children', []))
str(block.usage_key): _get_handler_url(block.usage_key, 'handler_name', request.user, version=version)
}
# Currently we don't support child blocks so we don't need this pre-loading of child handler URLs:
# handler_urls = {
# str(key): _get_handler_url(key, 'handler_name', request.user)
# for key in itertools.chain([block.scope_ids.usage_id], getattr(block, 'children', []))
# }
lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
context = {
'fragment': fragment,
@@ -205,7 +231,8 @@ def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name,
raise AuthenticationFailed("Invalid user ID format.")
request_webob = DjangoWebobRequest(request) # Convert from django request to the webob format that XBlocks expect
block = load_block(usage_key, user)
block = load_block(usage_key, user, version=parse_version_request(request.GET.get("version")))
# Run the handler, and save any resulting XBlock field value changes:
response_webob = block.handle(handler_name, request_webob, suffix)
response = webob_to_django_response(response_webob)
@@ -249,11 +276,15 @@ class BlockFieldsView(APIView):
# The "fields" view requires "read as author" permissions because the fields can contain answers, etc.
block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_READ_AS_AUTHOR)
# It would make more sense if this just had a "fields" dict with all the content+settings fields, but
# for backwards compatibility we call the settings metadata and split it up like this, ignoring all content
# fields except "data".
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),
"display_name": get_block_display_name(block), # note this is also present in metadata
"metadata": self.get_explicitly_set_fields_by_scope(block, Scope.settings),
}
if hasattr(block, "data"):
block_dict["data"] = block.data
return Response(block_dict)
@atomic
@@ -271,8 +302,8 @@ class BlockFieldsView(APIView):
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)
old_metadata = self.get_explicitly_set_fields_by_scope(block, Scope.settings)
old_content = self.get_explicitly_set_fields_by_scope(block, Scope.content)
# only update data if it was passed
if data is not None:
@@ -309,8 +340,26 @@ class BlockFieldsView(APIView):
context_impl = get_learning_context_impl(usage_key)
context_impl.send_block_updated_event(usage_key)
return Response({
"id": str(block.location),
"data": data,
"metadata": block.get_explicitly_set_fields_by_scope(Scope.settings),
})
block_dict = {
"id": str(block.usage_key),
"display_name": get_block_display_name(block), # note this is also present in metadata
"metadata": self.get_explicitly_set_fields_by_scope(block, Scope.settings),
}
if hasattr(block, "data"):
block_dict["data"] = block.data
return Response(block_dict)
def get_explicitly_set_fields_by_scope(self, block, scope=Scope.content):
"""
Get a dictionary of the fields for the given scope which are set explicitly on the given xblock.
(Including any set to None.)
"""
result = {}
for field in block.fields.values(): # lint-amnesty, pylint: disable=no-member
if field.scope == scope and field.is_set_on(block):
try:
result[field.name] = field.read_json(block)
except TypeError as exc:
raise TypeError(f"Unable to read field {field.name} from block {block.usage_key}") from exc
return result

View File

@@ -7,7 +7,7 @@ import logging
from collections import defaultdict
from datetime import datetime, timezone
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.transaction import atomic
from openedx_learning.api import authoring as authoring_api
@@ -20,6 +20,7 @@ from xblock.fields import Field, Scope, ScopeIds
from xblock.field_data import FieldData
from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core
from ..data import AuthoredDataMode, LatestVersion
from ..learning_context.manager import get_learning_context_impl
from .runtime import XBlockRuntime
@@ -161,7 +162,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
(eventually) asset storage.
"""
def get_block(self, usage_key, for_parent=None):
def get_block(self, usage_key, for_parent=None, *, version: int | LatestVersion = LatestVersion.AUTO):
"""
Fetch an XBlock from Learning Core data models.
@@ -173,10 +174,21 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
# We can do this more efficiently in a single query later, but for now
# just get it the easy way.
component = self._get_component_from_usage_key(usage_key)
# TODO: For now, this runtime will only be used in CMS, so it's fine to just return the Draft version.
# However, we will need the runtime to return the Published version for LMS (and Draft for LMS-Preview).
# We should base this Draft vs Published decision on a runtime initialization parameter.
component_version = component.versioning.draft
if version == LatestVersion.AUTO:
if self.authored_data_mode == AuthoredDataMode.DEFAULT_DRAFT:
version = LatestVersion.DRAFT
else:
version = LatestVersion.PUBLISHED
if self.authored_data_mode == AuthoredDataMode.STRICTLY_PUBLISHED and version != LatestVersion.PUBLISHED:
raise ValidationError("This runtime only allows accessing the published version of components")
if version == LatestVersion.DRAFT:
component_version = component.versioning.draft
elif version == LatestVersion.PUBLISHED:
component_version = component.versioning.published
else:
assert isinstance(version, int)
component_version = component.versioning.version_num(version)
if component_version is None:
raise NoSuchUsage(usage_key)
@@ -205,6 +217,9 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
else:
block = block_class.parse_xml(xml_node, runtime=self, keys=keys)
# Store the version request on the block so we can retrieve it when needed for generating handler URLs etc.
block._runtime_requested_version = version # pylint: disable=protected-access
# Update field data with parsed values. We can't call .save() because it will call save_block(), below.
block.force_save_fields(block._get_fields_to_save()) # pylint: disable=protected-access
@@ -224,6 +239,13 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
if not self.authored_data_store.has_changes(block):
return # No changes, so no action needed.
if block._runtime_requested_version != LatestVersion.DRAFT: # pylint: disable=protected-access
# Not sure if this is an important restriction but it seems like overwriting the latest version based on
# an old version is likely an accident, so for now we're not going to allow it.
raise ValidationError(
"Do not make changes to a component starting from the published or past versions. Use the latest draft."
)
# Verify that the user has permission to write to authored data in this
# learning context:
if self.user is not None:

View File

@@ -1,9 +1,8 @@
"""
Common base classes for all new XBlock runtimes.
"""
from __future__ import annotations
import logging
from typing import Callable, Optional
from typing import Callable, Protocol
from urllib.parse import urljoin # pylint: disable=import-error
import crum
@@ -15,7 +14,7 @@ from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from eventtracking import tracker
from opaque_keys.edx.keys import UsageKey, LearningContextKey
from opaque_keys.edx.keys import UsageKeyV2, LearningContextKey
from web_fragments.fragment import Fragment
from xblock.core import XBlock
from xblock.exceptions import NoSuchServiceError
@@ -38,7 +37,7 @@ from lms.djangoapps.grades.api import signals as grades_signals
from openedx.core.types import User as UserType
from openedx.core.djangoapps.enrollments.services import EnrollmentsService
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
from openedx.core.djangoapps.xblock.data import StudentDataMode
from openedx.core.djangoapps.xblock.data import AuthoredDataMode, StudentDataMode, LatestVersion
from openedx.core.djangoapps.xblock.runtime.ephemeral_field_data import EphemeralKeyValueStore
from openedx.core.djangoapps.xblock.runtime.mixin import LmsBlockMixin
from openedx.core.djangoapps.xblock.utils import get_xblock_id_for_anonymous_user
@@ -63,6 +62,19 @@ def make_track_function():
return function
class GetHandlerFunction(Protocol):
""" Type definition for our "get handler" callback """
def __call__(
self,
usage_key: UsageKeyV2,
handler_name: str,
user: UserType | None,
*,
version: int | LatestVersion = LatestVersion.AUTO,
) -> str:
...
class XBlockRuntime(RuntimeShim, Runtime):
"""
This class manages one or more instantiated XBlocks for a particular user,
@@ -94,15 +106,18 @@ class XBlockRuntime(RuntimeShim, Runtime):
# keep track of view name (student_view, studio_view, etc)
# currently only used to track if we're in the studio_view (see below under service())
view_name: str | None
# backing store for authored field data (mostly content+settings scopes)
authored_data_store: FieldData
def __init__(
self,
user: UserType | None,
*,
handler_url: Callable[[UsageKey, str, UserType | None], str],
handler_url: GetHandlerFunction,
student_data_mode: StudentDataMode,
id_reader: Optional[IdReader] = None,
authored_data_store: Optional[FieldData] = None,
authored_data_mode: AuthoredDataMode,
authored_data_store: FieldData,
id_reader: IdReader | None = None,
):
super().__init__(
id_reader=id_reader or OpaqueKeyReader(),
@@ -115,6 +130,7 @@ class XBlockRuntime(RuntimeShim, Runtime):
id_generator=MemoryIdManager(), # We don't really use id_generator until we need to support asides
)
assert student_data_mode in (StudentDataMode.Ephemeral, StudentDataMode.Persisted)
self.authored_data_mode = authored_data_mode
self.authored_data_store = authored_data_store
self.children_data_store = None
self.student_data_mode = student_data_mode
@@ -138,7 +154,10 @@ class XBlockRuntime(RuntimeShim, Runtime):
if thirdparty:
log.warning("thirdparty handlers are not supported by this runtime for XBlock %s.", type(block))
url = self.handler_url_fn(block.scope_ids.usage_id, handler_name, self.user)
# Note: it's important that we call handlers based on the same version of the block
# (draft block -> draft data available to handler; published block -> published data available to handler)
kwargs = {"version": block._runtime_requested_version} if hasattr(block, "_runtime_requested_version") else {} # pylint: disable=protected-access
url = self.handler_url_fn(block.usage_key, handler_name, self.user, **kwargs)
if suffix:
if not url.endswith('/'):
url += '/'