# 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 = '' 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