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

XBV2 Unit Rendering Prototype #34887

Closed
Closed
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
16 changes: 14 additions & 2 deletions lms/djangoapps/courseware/block_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,9 +792,21 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
request.user.known = request.user.is_authenticated

try:
course_key = CourseKey.from_string(course_id)
usage_key = UsageKey.from_string(usage_id)
except InvalidKeyError:
raise Http404(f'{course_id} is not a valid course key') # lint-amnesty, pylint: disable=raise-missing-from
raise Http404(f'{usage_id} is not a valid usage key') # lint-amnesty, pylint: disable=raise-missing-from
course_key = usage_key.context_key
if course_id:
# This API used to require specifying _both_ the course and usage keys, even though the course key can be
# trivially derived from the usage key. If both are set, verify that they match:
try:
course_key_explicit = CourseKey.from_string(course_id)
except InvalidKeyError:
raise Http404(f'{course_id} is not a valid course key') # lint-amnesty, pylint: disable=raise-missing-from
if course_key_explicit != course_key:
raise Http404(f'{course_id} does not match the course of the usage key {usage_id}')
else:
course_id = str(course_key)

with modulestore().bulk_operations(course_key):
try:
Expand Down
145 changes: 145 additions & 0 deletions lms/djangoapps/courseware/unit_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""
API to render all of the XBlocks in a given unit
"""
import json
import logging
import time

from django.urls import reverse
from opaque_keys.edx.keys import UsageKey
from rest_framework.decorators import api_view
from rest_framework.exceptions import ValidationError
from xblock.fields import Scope
from xblock.core import XBlock, XBlock2Mixin

from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.courseware.models import StudentModule, XModuleUserStateSummaryField
from openedx.core.lib.api.view_utils import view_auth_classes
from common.djangoapps.util.json_request import JsonResponse
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.util.keys import BlockKey

log = logging.getLogger(__name__)


@api_view(['GET'])
@view_auth_classes(is_authenticated=True)
def get_unit_blocks(request, usage_id):
"""
Get (as JSON) the data required to render all of the XBlocks [that the
current user can see] in the given unit.

Note: a unit is implemented as a "vertical" XBlock with children, but part
of the goal of this API is to abstract that away, as part of our long-term
goal of separating the course outline tree from XBlocks (components/leaves).
"""
start_time = time.time()
unit_usage_key = UsageKey.from_string(usage_id)
course_key = unit_usage_key.context_key

if unit_usage_key.block_type not in ("vertical", "unit"):
raise ValidationError({"usage_id": "Not a unit"})
if not course_key.is_course:
raise ValidationError({"usage_id": "Not from a course. This API only works with XBlocks in modulestore."})

store = modulestore()._get_modulestore_for_courselike(course_key)
with store.bulk_operations(course_key):
# Bypass normal modulestore functionality so we can load the list of XBlocks in this unit without loading
# The actual blocks, runtime, etc.
structure_data = store._lookup_course(course_key.for_branch(ModuleStoreEnum.BranchName.published))

blocks_data = get_course_blocks(request.user, unit_usage_key)
# Get the usage keys of all the XBlocks in this unit:
unit_block_ids = blocks_data.get_children(unit_usage_key)

student_modules = StudentModule.objects.filter(
student_id=request.user.id,
course_id=course_key,
module_state_key__in=[str(key) for key in unit_block_ids],
)

blocks = []
for usage_key in unit_block_ids:
block_data_out = {
"id": str(usage_key),
"block_type": usage_key.block_type,
}
try:
block_class = XBlock.load_class(usage_key.block_type)
if issubclass(block_class, XBlock2Mixin):
block_data_out["xblock_api_version"] = 2
block_data_out["content_fields"] = {}
block_data_out["user_fields"] = {}

def add_field(field_name, value):
field = block_class.fields.get(field_name)
if field: # TODO: and not field.private
block_data_out["content_fields"][field_name] = field.to_json(value)
# Do we even need to share these "system fields" with the frontend? probably not...
# else:
# for mixin in store.xblock_mixins:
# field = mixin.fields.get(field_name)
# if field:
# block_data_out["system_fields"][field_name] = field.to_json(value)
# return

# We cannot get ALL of the field data from the block transfomers API(s), because they only support a
# small subset of fields defined in course_api.blocks.serializers.SUPPORTED_FIELDS. However, all the
# "complex" fields where we need to worry about inheritance etc. are in the block transformers API
for field_name, value in blocks_data[usage_key].fields.items():
add_field(field_name, value)
# Note: "fields" like "has_score", "course_version", "completion_mode", "subtree_edited_on"
# and "category" will be silently dropped here since they're in the block transformer data but
# they aren't actual XBlock fields. (Except lti_block.has_score which is actually a field.)

# Load additional fields from split modulestore if needed.
block_data = store._get_block_from_structure(
structure_data.structure,
BlockKey.from_usage_key(usage_key),
)
definition = store.get_definition(course_key, block_data.definition)
for field_name, value in definition["fields"].items():
add_field(field_name, value) # TODO: maybe this is already JSON-compatible? don't need to_json?

# Get the user-specific field data:
sm = next((sm for sm in student_modules if sm.module_state_key == usage_key), None)
user_state_data = json.loads(sm.state) if sm else {}

for field_name, field in block_class.fields.items():
if field.scope == Scope.user_state:
if field_name in user_state_data:
# Note: this value is already in a JSON-compatible format
block_data_out["user_fields"][field_name] = user_state_data[field_name]
else:
block_data_out["user_fields"][field_name] = field.to_json(field.default)
elif field.scope == Scope.user_state_summary:
try:
uss = XModuleUserStateSummaryField.objects.get(
usage_id=usage_key,
field_name=field_name,
)
block_data_out["user_fields"][field_name] = json.loads(uss.value)
except XModuleUserStateSummaryField.DoesNotExist:
block_data_out["user_fields"][field_name] = field.to_json(field.default)

else:
block_data_out["xblock_api_version"] = 1
block_data_out["embed_uri"] = request.build_absolute_uri(
reverse("render_xblock", kwargs={"usage_key_string": str(usage_key)})
)
except Exception as err:
log.exception(f"Unable to load field data for {usage_key}")
block_data_out["error"] = type(err).__name__
finally:
blocks.append(block_data_out)

end_time = time.time()
print(f"✅ rendering the unit took {(end_time - start_time)*1000:.0f}ms on the backend.")

return JsonResponse({
"unit": {
"display_name": blocks_data[unit_usage_key].fields.get("display_name")
},
"blocks": blocks,
})
19 changes: 19 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
xblock_view,
xqueue_callback
)
from lms.djangoapps.courseware.unit_render import get_unit_blocks
from lms.djangoapps.courseware.views import views as courseware_views
from lms.djangoapps.courseware.views.index import CoursewareIndex
from lms.djangoapps.courseware.views.views import CourseTabView, EnrollStaffView, StaticCourseTabView
Expand Down Expand Up @@ -287,6 +288,14 @@
handle_xblock_callback,
name='xblock_handler',
),
re_path(
r'^xblock/{usage_key}/handler/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$'.format(
usage_key=settings.USAGE_ID_PATTERN,
),
handle_xblock_callback,
{"course_id": None},
name='xblock_handler_no_course',
),
re_path(
r'^courses/{course_key}/xblock/{usage_key}/handler_noauth/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$'.format(
course_key=settings.COURSE_ID_PATTERN,
Expand Down Expand Up @@ -328,6 +337,16 @@
name=RENDER_VIDEO_XBLOCK_NAME,
),

# NEW API to render all of the XBlocks in a given unit. Returns JSON data so
# that the frontend (or mobile app) can render the XBlock.
re_path(
r'^api/xblock/v1/{usage_key}/unit_contents$'.format(
usage_key=settings.USAGE_ID_PATTERN,
),
get_unit_blocks,
name='get_unit_blocks',
),


# xblock Resource URL
re_path(
Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/common_views/xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import mimetypes

from django.http import Http404, HttpResponse
from xblock.core import XBlock
from xblock.core import XBlock, XBlock2

log = logging.getLogger(__name__)

Expand Down
4 changes: 4 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"word_cloud = xmodule.word_cloud_block:WordCloudBlock",
"wrapper = xmodule.wrapper_block:WrapperBlock",
]
XBLOCKS_V2 = [
"html = xmodule.html_block:HtmlBlockV2",
]
XBLOCKS_ASIDES = [
'tagging_aside = cms.lib.xblock.tagging:StructuredTagsAside',
]
Expand Down Expand Up @@ -186,6 +189,7 @@
'team = openedx.core.lib.teams_config:create_team_set_partition',
],
'xblock.v1': XBLOCKS,
'xblock.v2': XBLOCKS_V2,
'xblock_asides.v1': XBLOCKS_ASIDES,
'console_scripts': [
'xmodule_assets = xmodule.static_content:main',
Expand Down
18 changes: 18 additions & 0 deletions xmodule/assets/html/public/learner-view-v2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// @ts-check
import {
html,
useFields,
registerPreactXBlock,
} from 'xblock2-client-v0';

function HTMLBlock(props) {
const {
data,
} = useFields(props);

return html`
<div dangerouslySetInnerHTML=${{__html: data}}></div>
`;
}

registerPreactXBlock(HTMLBlock, 'html', {shadow: false});
5 changes: 3 additions & 2 deletions xmodule/html_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from lxml import etree
from path import Path as path
from web_fragments.fragment import Fragment
from xblock.core import XBlock
from xblock.core import XBlock, XBlock2Mixin
from xblock.fields import Boolean, List, Scope, String

from common.djangoapps.xblock_django.constants import ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID
Expand Down Expand Up @@ -353,11 +353,12 @@ def index_dictionary(self):


@edxnotes
class HtmlBlock(HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method
class HtmlBlock(XBlock2Mixin, HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method
"""
This is the actual HTML XBlock.
Nothing extra is required; this is just a wrapper to include edxnotes support.
"""
resources_dir = "assets/html"


class AboutFields: # lint-amnesty, pylint: disable=missing-class-docstring
Expand Down
44 changes: 25 additions & 19 deletions xmodule/x_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from web_fragments.fragment import Fragment
from webob import Response
from webob.multidict import MultiDict
from xblock.core import XBlock, XBlockAside
from xblock.core import XBlock, XBlock2Mixin, XBlockAside
from xblock.fields import (
Dict,
Float,
Expand Down Expand Up @@ -586,28 +586,34 @@ def bind_for_student(self, user_id, wrappers=None):
if getattr(self.runtime, 'position', None):
self.position = self.runtime.position # update the position of the tab
return

if isinstance(self, XBlock2Mixin):
if self.scope_ids.user_id is not None:
raise RuntimeError("v2 XBlocks cannot be rebound to a different user")
# Update scope_ids to point to the new user.
self.scope_ids = self.scope_ids._replace(user_id=user_id)
else:
# If we are switching users mid-request, save the data from the old user.
self.save()

# If we are switching users mid-request, save the data from the old user.
self.save()

# Update scope_ids to point to the new user.
self.scope_ids = self.scope_ids._replace(user_id=user_id)
# Update scope_ids to point to the new user.
self.scope_ids = self.scope_ids._replace(user_id=user_id)

# Clear out any cached instantiated children.
self.clear_child_cache()
# Clear out any cached instantiated children.
self.clear_child_cache()

# Clear out any cached field data scoped to the old user.
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member
if field.scope in (Scope.parent, Scope.children):
continue
# Clear out any cached field data scoped to the old user.
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member
if field.scope in (Scope.parent, Scope.children):
continue

if field.scope.user == UserScope.ONE:
field._del_cached_value(self) # pylint: disable=protected-access
# not the most elegant way of doing this, but if we're removing
# a field from the module's field_data_cache, we should also
# remove it from its _dirty_fields
if field in self._dirty_fields:
del self._dirty_fields[field]
if field.scope.user == UserScope.ONE:
field._del_cached_value(self) # pylint: disable=protected-access
# not the most elegant way of doing this, but if we're removing
# a field from the module's field_data_cache, we should also
# remove it from its _dirty_fields
if field in self._dirty_fields:
del self._dirty_fields[field]

if wrappers:
# Put user-specific wrappers around the field-data service for this block.
Expand Down
Loading