* feat: allow editing html block imported from upstream The modified field is left untouched in future sync while storing the upstream values in hidden fields to allow authors to revert to upstream version at any point. * fix: sync downstream_customized field for copy-pasted modified block * test: add more tests * fix: lint issues * test: copy paste * feat: skip sync if html data is modified * feat: update upstream fields only when modified * refactor: use version_synced field to skip sync * feat: edit title inplace for library source components * fixup! feat: edit title inplace for library source components * fix: edit title button style * fix: test case * fix: lint issue * refactor: don't show different icon for modified upstream blocks * Revert "refactor: use version_synced field to skip sync" This reverts commit 8b784fff2f49b43702c952e7f955bd4048e8cc69. * feat: only skip sync for modified blocks if updated as part of container * refactor: update sync behaviour when synced individually and as part of parent * feat: include ready to sync children info in downstream link get api * test: fix failing tests * fix: lint issues * feat: new tests and update api to allow overriding modified fields in sync * test: api changes * refactor: edit options should be visible for individual imports * docs: update api docs * chore: remove old comments
379 lines
15 KiB
Python
379 lines
15 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.
|
|
"""
|
|
# Allow some imported components to be edited by authors in course.
|
|
editable_library_components = ["html"]
|
|
# 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)
|
|
can_move = context.get('can_move', True)
|
|
root_upstream_link = UpstreamLink.try_get_for_block(root_xblock, log_error=False)
|
|
upstream_link = UpstreamLink.try_get_for_block(xblock, log_error=False)
|
|
if (
|
|
root_upstream_link.error_message is None
|
|
and isinstance(root_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
|
|
can_move = False
|
|
|
|
if upstream_link.error_message is None and upstream_link.upstream_ref:
|
|
can_edit = xblock.category in editable_library_components
|
|
|
|
# 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': can_move,
|
|
'language': getattr(course, 'language', None),
|
|
'is_course': is_course,
|
|
'tags_count': tags_count,
|
|
'can_edit_title': True, # This is always true even for imported components
|
|
}
|
|
|
|
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
|