* feat: library unit sync * feat: create component link only for component xblocks * feat: container link model * feat: update downstream api views * feat: delete extra components in container on sync (not working) * fix: duplicate definitions of LibraryXBlockMetadata * test: add a new integration test suite for syncing * feat: partially implement container+child syncing * fix: blockserializer wasn't always serializing all HTML block fields * feat: handle reorder, addition and deletion of components in sync Updates children components of unit in course based on upstream unit, deletes removed component, adds new ones and updates order as per upstream. * feat: return unit upstreamInfo and disallow edits to units in courses that are sourced from a library (#773) * feat: Add upstream_info to unit * feat: disallow edits to units in courses that are sourced from a library (#774) --------- Co-authored-by: Jillian Vogel <jill@opencraft.com> Co-authored-by: Rômulo Penido <romulo.penido@gmail.com> * docs: capitalization of XBlock Co-authored-by: David Ormsbee <dave@axim.org> * refactor: (minor) change python property name to reflect type better * fix: lots of "Tried to inspect a missing...upstream link" warnings when viewing a unit in Studio * docs: mention potential REST API for future refactor * fix: check if upstream actually exists before making unit read-only * chore: fix camel-case var * fix: test failure when mocked XBlock doesn't have UpstreamSyncMixin --------- Co-authored-by: Braden MacDonald <braden@opencraft.com> Co-authored-by: Chris Chávez <xnpiochv@gmail.com> Co-authored-by: Jillian Vogel <jill@opencraft.com> Co-authored-by: Rômulo Penido <romulo.penido@gmail.com> Co-authored-by: Braden MacDonald <mail@bradenm.com> Co-authored-by: David Ormsbee <dave@axim.org>
367 lines
14 KiB
Python
367 lines
14 KiB
Python
# lint-amnesty, pylint: disable=missing-module-docstring
|
|
|
|
import logging
|
|
from functools import partial
|
|
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.http import Http404, HttpResponseBadRequest
|
|
from django.urls import reverse
|
|
from django.utils.translation import gettext as _
|
|
from django.views.decorators.clickjacking import xframe_options_exempt
|
|
from opaque_keys.edx.keys import UsageKey
|
|
from opaque_keys.edx.locator import LibraryContainerLocator
|
|
from rest_framework.request import Request
|
|
from web_fragments.fragment import Fragment
|
|
from xblock.django.request import django_to_webob_request, webob_to_django_response
|
|
from xblock.exceptions import NoSuchHandlerError
|
|
from xblock.runtime import KvsFieldData
|
|
|
|
from xmodule.contentstore.django import contentstore
|
|
from xmodule.exceptions import NotFoundError, ProcessingError
|
|
from xmodule.modulestore.django import XBlockI18nService, modulestore
|
|
from xmodule.partitions.partitions_service import PartitionService
|
|
from xmodule.services import SettingsService, TeamsConfigurationService
|
|
from xmodule.studio_editable import has_author_view
|
|
from xmodule.util.sandboxing import SandboxService
|
|
from xmodule.util.builtin_assets import add_webpack_js_to_fragment
|
|
from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMixin
|
|
from cms.djangoapps.xblock_config.models import StudioConfig
|
|
from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id
|
|
from cms.lib.xblock.field_data import CmsFieldData
|
|
from cms.lib.xblock.upstream_sync import UpstreamLink
|
|
from common.djangoapps.static_replace.services import ReplaceURLService
|
|
from common.djangoapps.static_replace.wrapper import replace_urls_wrapper
|
|
from common.djangoapps.student.models import anonymous_id_for_user
|
|
from common.djangoapps.edxmako.shortcuts import render_to_string
|
|
from common.djangoapps.edxmako.services import MakoService
|
|
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
|
|
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
|
|
from openedx.core.lib.license import wrap_with_license
|
|
from openedx.core.lib.cache_utils import CacheService
|
|
from openedx.core.lib.xblock_utils import (
|
|
request_token,
|
|
wrap_fragment,
|
|
wrap_xblock,
|
|
wrap_xblock_aside
|
|
)
|
|
|
|
from ..utils import StudioPermissionsService, get_visibility_partition_info
|
|
from .access import get_user_role
|
|
from .session_kv_store import SessionKeyValueStore
|
|
|
|
__all__ = ['preview_handler']
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@login_required
|
|
@xframe_options_exempt
|
|
def preview_handler(request, usage_key_string, handler, suffix=''):
|
|
"""
|
|
Dispatch an AJAX action to an xblock
|
|
|
|
usage_key_string: The usage_key_string-id of the block to dispatch to, passed through `quote_slashes`
|
|
handler: The handler to execute
|
|
suffix: The remainder of the url to be passed to the handler
|
|
"""
|
|
usage_key = UsageKey.from_string(usage_key_string)
|
|
|
|
block = modulestore().get_item(usage_key)
|
|
instance = _load_preview_block(request, block)
|
|
|
|
# Let the module handle the AJAX
|
|
req = django_to_webob_request(request)
|
|
try:
|
|
resp = instance.handle(handler, req, suffix)
|
|
|
|
except NoSuchHandlerError:
|
|
log.exception("XBlock %s attempted to access missing handler %r", instance, handler)
|
|
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
except NotFoundError:
|
|
log.exception("Module indicating to user that request doesn't exist")
|
|
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
|
|
|
except ProcessingError:
|
|
log.warning("Module raised an error while processing AJAX request",
|
|
exc_info=True)
|
|
return HttpResponseBadRequest()
|
|
|
|
except Exception:
|
|
log.exception("error processing ajax call")
|
|
raise
|
|
|
|
return webob_to_django_response(resp)
|
|
|
|
|
|
def handler_url(block, handler_name, suffix='', query='', thirdparty=False): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
Handler URL function for Preview
|
|
"""
|
|
return reverse('preview_handler', kwargs={
|
|
'usage_key_string': str(block.scope_ids.usage_id),
|
|
'handler': handler_name,
|
|
'suffix': suffix,
|
|
}) + '?' + query
|
|
|
|
|
|
def preview_applicable_aside_types(block, applicable_aside_types=None):
|
|
"""
|
|
Remove acid_aside and honor the config record
|
|
"""
|
|
if not StudioConfig.asides_enabled(block.scope_ids.block_type):
|
|
return []
|
|
|
|
# TODO: aside_type != 'acid_aside' check should be removed once AcidBlock is only installed during tests
|
|
# (see https://openedx.atlassian.net/browse/TE-811)
|
|
return [
|
|
aside_type
|
|
for aside_type in applicable_aside_types(block)
|
|
if aside_type != 'acid_aside'
|
|
]
|
|
|
|
|
|
def render_child_placeholder(block, view_name, context, wrap_block=None):
|
|
"""
|
|
Renders a placeholder XBlock.
|
|
"""
|
|
return wrap_block(block, view_name, Fragment(), context)
|
|
|
|
|
|
def preview_layout_asides(block, context, frag, view_name, aside_frag_fns, wrap_aside=None):
|
|
"""
|
|
Custom layout of asides for preview
|
|
"""
|
|
position_for_asides = '<!-- footer for xblock_aside -->'
|
|
result = Fragment()
|
|
result.add_fragment_resources(frag)
|
|
|
|
for aside, aside_fn in aside_frag_fns:
|
|
aside_frag = aside_fn(block, context)
|
|
if aside_frag.content != '':
|
|
aside_frag_wrapped = wrap_aside(block, aside, view_name, aside_frag, context)
|
|
aside.save()
|
|
result.add_fragment_resources(aside_frag_wrapped)
|
|
replacement = position_for_asides + aside_frag_wrapped.content
|
|
frag.content = frag.content.replace(position_for_asides, replacement)
|
|
|
|
result.add_content(frag.content)
|
|
return result
|
|
|
|
|
|
def _prepare_runtime_for_preview(request, block):
|
|
"""
|
|
Sets properties in the runtime of the specified block that is
|
|
required for rendering block previews.
|
|
|
|
request: The active django request
|
|
block: An XBlock
|
|
"""
|
|
|
|
course_id = block.location.course_key
|
|
display_name_only = (block.category == 'static_tab')
|
|
|
|
wrappers = [
|
|
# This wrapper wraps the block in the template specified above
|
|
partial(
|
|
wrap_xblock,
|
|
'PreviewRuntime',
|
|
display_name_only=display_name_only,
|
|
usage_id_serializer=str,
|
|
request_token=request_token(request)
|
|
),
|
|
|
|
# This wrapper replaces urls in the output that start with /static
|
|
# with the correct course-specific url for the static content
|
|
partial(replace_urls_wrapper, replace_url_service=ReplaceURLService, static_replace_only=True),
|
|
_studio_wrap_xblock,
|
|
]
|
|
|
|
wrappers_asides = [
|
|
partial(
|
|
wrap_xblock_aside,
|
|
'PreviewRuntime',
|
|
usage_id_serializer=str,
|
|
request_token=request_token(request)
|
|
)
|
|
]
|
|
|
|
mako_service = MakoService(namespace_prefix='lms.')
|
|
if settings.FEATURES.get("LICENSING", False):
|
|
# stick the license wrapper in front
|
|
wrappers.insert(0, partial(wrap_with_license, mako_service=mako_service))
|
|
|
|
anonymous_user_id = deprecated_anonymous_user_id = 'student'
|
|
if individualize_anonymous_user_id(course_id):
|
|
anonymous_user_id = anonymous_id_for_user(request.user, course_id)
|
|
# See the docstring of `DjangoXBlockUserService`.
|
|
deprecated_anonymous_user_id = anonymous_id_for_user(request.user, None)
|
|
|
|
services = {
|
|
"studio_user_permissions": StudioPermissionsService(request.user),
|
|
"i18n": XBlockI18nService,
|
|
'mako': mako_service,
|
|
"settings": SettingsService(),
|
|
"user": DjangoXBlockUserService(
|
|
request.user,
|
|
user_role=get_user_role(request.user, course_id),
|
|
anonymous_user_id=anonymous_user_id,
|
|
deprecated_anonymous_user_id=deprecated_anonymous_user_id,
|
|
),
|
|
"partitions": StudioPartitionService(course_id=course_id),
|
|
"teams_configuration": TeamsConfigurationService(),
|
|
"sandbox": SandboxService(contentstore=contentstore, course_id=course_id),
|
|
"cache": CacheService(cache),
|
|
'replace_urls': ReplaceURLService
|
|
}
|
|
|
|
block.runtime.get_block_for_descriptor = partial(_load_preview_block, request)
|
|
block.runtime.mixins = settings.XBLOCK_MIXINS
|
|
|
|
# Set up functions to modify the fragment produced by student_view
|
|
block.runtime.wrappers = wrappers
|
|
block.runtime.wrappers_asides = wrappers_asides
|
|
block.runtime._services.update(services) # pylint: disable=protected-access
|
|
|
|
# xmodules can check for this attribute during rendering to determine if
|
|
# they are being rendered for preview (i.e. in Studio)
|
|
block.runtime.is_author_mode = True
|
|
block.runtime.handler_url_override = handler_url
|
|
block.runtime.applicable_aside_types_override = preview_applicable_aside_types
|
|
block.runtime.render_child_placeholder = partial(
|
|
render_child_placeholder,
|
|
wrap_block=block.runtime.wrap_xblock
|
|
)
|
|
block.runtime.layout_asides_override = partial(
|
|
preview_layout_asides,
|
|
wrap_aside=block.runtime.wrap_aside
|
|
)
|
|
|
|
|
|
class StudioPartitionService(PartitionService):
|
|
"""
|
|
A runtime mixin to allow the display and editing of component visibility based on user partitions.
|
|
"""
|
|
def get_user_group_id_for_partition(self, user, user_partition_id):
|
|
"""
|
|
Override this method to return None, as the split_test_block calls this
|
|
to determine which group a user should see, but is robust to getting a return
|
|
value of None meaning that all groups should be shown.
|
|
"""
|
|
return None
|
|
|
|
|
|
def _load_preview_block(request: Request, block: XModuleMixin):
|
|
"""
|
|
Return a preview XBlock instantiated from the supplied block. Will use mutable fields
|
|
if XBlock supports an author_view. Otherwise, will use immutable fields and student_view.
|
|
|
|
request: The active django request
|
|
block: An XModuleMixin
|
|
"""
|
|
student_data = KvsFieldData(SessionKeyValueStore(request))
|
|
if has_author_view(block):
|
|
wrapper = partial(CmsFieldData, student_data=student_data)
|
|
else:
|
|
wrapper = partial(LmsFieldData, student_data=student_data)
|
|
|
|
_prepare_runtime_for_preview(request, block)
|
|
|
|
block.bind_for_student(
|
|
request.user.id,
|
|
[wrapper]
|
|
)
|
|
return block
|
|
|
|
|
|
def _is_xblock_reorderable(xblock, context):
|
|
"""
|
|
Returns true if the specified xblock is in the set of reorderable xblocks
|
|
otherwise returns false.
|
|
"""
|
|
try:
|
|
return xblock.location in context['reorderable_items']
|
|
except KeyError:
|
|
return False
|
|
|
|
|
|
# pylint: disable=unused-argument
|
|
def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
|
"""
|
|
Wraps the results of rendering an XBlock view in a div which adds a header and Studio action buttons.
|
|
"""
|
|
# Only add the Studio wrapper when on the container page. The "Pages" page will remain as is for now.
|
|
if not context.get('is_pages_view', None) and view in PREVIEW_VIEWS:
|
|
root_xblock = context.get('root_xblock')
|
|
is_root = root_xblock and xblock.location == root_xblock.location
|
|
is_reorderable = _is_xblock_reorderable(xblock, context)
|
|
selected_groups_label = get_visibility_partition_info(xblock)['selected_groups_label']
|
|
if selected_groups_label:
|
|
selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long
|
|
course = modulestore().get_course(xblock.location.course_key)
|
|
|
|
can_edit = context.get('can_edit', True)
|
|
can_add = context.get('can_add', True)
|
|
upstream_link = UpstreamLink.try_get_for_block(root_xblock, log_error=False)
|
|
if upstream_link.error_message is None and isinstance(upstream_link.upstream_key, LibraryContainerLocator):
|
|
# If this unit is linked to a library unit, for now we make it completely read-only
|
|
# because when it is synced, all local changes like added components will be lost.
|
|
# (This is only on the frontend; the backend doesn't enforce it)
|
|
can_edit = False
|
|
can_add = False
|
|
|
|
# Is this a course or a library?
|
|
is_course = xblock.context_key.is_course
|
|
tags_count_map = context.get('tags_count_map')
|
|
tags_count = 0
|
|
if tags_count_map:
|
|
tags_count = tags_count_map.get(str(xblock.location), 0)
|
|
template_context = {
|
|
'xblock_context': context,
|
|
'xblock': xblock,
|
|
'show_preview': context.get('show_preview', True),
|
|
'content': frag.content,
|
|
'is_root': is_root,
|
|
'is_reorderable': is_reorderable,
|
|
'can_edit': can_edit,
|
|
'can_edit_visibility': can_edit and context.get('can_edit_visibility', is_course),
|
|
'course_authoring_url': settings.COURSE_AUTHORING_MICROFRONTEND_URL,
|
|
'is_loading': context.get('is_loading', False),
|
|
'is_selected': context.get('is_selected', False),
|
|
'selectable': context.get('selectable', False),
|
|
'selected_groups_label': selected_groups_label,
|
|
'can_add': can_add,
|
|
# Generally speaking, "if you can add, you can delete". One exception is itembank (Problem Bank)
|
|
# which has its own separate "add" workflow but uses the normal delete workflow for its child blocks.
|
|
'can_delete': can_add or (root_xblock and root_xblock.scope_ids.block_type == "itembank" and can_edit),
|
|
'can_move': context.get('can_move', is_course),
|
|
'language': getattr(course, 'language', None),
|
|
'is_course': is_course,
|
|
'tags_count': tags_count,
|
|
}
|
|
|
|
add_webpack_js_to_fragment(frag, "js/factories/xblock_validation")
|
|
|
|
html = render_to_string('studio_xblock_wrapper.html', template_context)
|
|
frag = wrap_fragment(frag, html)
|
|
return frag
|
|
|
|
|
|
def get_preview_fragment(request, block, context):
|
|
"""
|
|
Returns the HTML returned by the XModule's student_view or author_view (if available),
|
|
specified by the block and idx.
|
|
"""
|
|
block = _load_preview_block(request, block)
|
|
|
|
preview_view = AUTHOR_VIEW if has_author_view(block) else STUDENT_VIEW
|
|
|
|
try:
|
|
fragment = block.render(preview_view, context)
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
log.warning("Unable to render %s for %r", preview_view, block, exc_info=True)
|
|
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
|
|
return fragment
|