Files
edx-platform/cms/djangoapps/contentstore/views/preview.py
Navin Karkera 1cd73d1b96 feat: support for syncing units from libraries to courses (#36553)
* 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>
2025-04-24 11:41:47 -07:00

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