""" Helper methods for Studio views. """ from __future__ import annotations import logging import urllib from lxml import etree from mimetypes import guess_type from attrs import frozen, Factory from django.conf import settings from django.utils.translation import gettext as _ from opaque_keys.edx.keys import AssetKey, CourseKey, UsageKey from opaque_keys.edx.locator import DefinitionLocator, LocalId from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.runtime import IdGenerator from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError from xmodule.modulestore.django import modulestore from xmodule.xml_block import XmlMixin from cms.djangoapps.models.settings.course_grading import CourseGradingModel from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers import openedx.core.djangoapps.content_staging.api as content_staging_api import openedx.core.djangoapps.content_tagging.api as content_tagging_api from .utils import reverse_course_url, reverse_library_url, reverse_usage_url log = logging.getLogger(__name__) # Note: Grader types are used throughout the platform but most usages are simply in-line # strings. In addition, new grader types can be defined on the fly anytime one is needed # (because they're just strings). This dict is an attempt to constrain the sprawl in Studio. GRADER_TYPES = { "HOMEWORK": "Homework", "LAB": "Lab", "ENTRANCE_EXAM": "Entrance Exam", "MIDTERM_EXAM": "Midterm Exam", "FINAL_EXAM": "Final Exam" } def get_parent_xblock(xblock): """ Returns the xblock that is the parent of the specified xblock, or None if it has no parent. """ locator = xblock.location parent_location = modulestore().get_parent_location(locator) if parent_location is None: return None return modulestore().get_item(parent_location) def is_unit(xblock, parent_xblock=None): """ Returns true if the specified xblock is a vertical that is treated as a unit. A unit is a vertical that is a direct child of a sequential (aka a subsection). """ if xblock.category == 'vertical': if parent_xblock is None: parent_xblock = get_parent_xblock(xblock) parent_category = parent_xblock.category if parent_xblock else None return parent_category == 'sequential' return False def xblock_has_own_studio_page(xblock, parent_xblock=None): """ Returns true if the specified xblock has an associated Studio page. Most xblocks do not have their own page but are instead shown on the page of their parent. There are a few exceptions: 1. Courses 2. Verticals that are either: - themselves treated as units - a direct child of a unit 3. XBlocks that support children """ category = xblock.category if is_unit(xblock, parent_xblock): return True elif category == 'vertical': if parent_xblock is None: parent_xblock = get_parent_xblock(xblock) return is_unit(parent_xblock) if parent_xblock else False # All other xblocks with children have their own page return xblock.has_children def xblock_studio_url(xblock, parent_xblock=None, find_parent=False): """ Returns the Studio editing URL for the specified xblock. You can pass the parent xblock as an optimization, to avoid needing to load it twice, as sometimes the parent has to be checked. If you pass in a leaf block that doesn't have its own Studio page, this will normally return None, but if you use find_parent=True, this will find the nearest ancestor (usually the parent unit) that does have a Studio page and return that URL. """ if not xblock_has_own_studio_page(xblock, parent_xblock): if find_parent: while xblock and not xblock_has_own_studio_page(xblock, parent_xblock): xblock = parent_xblock or get_parent_xblock(xblock) parent_xblock = None if not xblock: return None else: return None category = xblock.category if category == 'course': return reverse_course_url('course_handler', xblock.location.course_key) elif category in ('chapter', 'sequential'): return '{url}?show={usage_key}'.format( url=reverse_course_url('course_handler', xblock.location.course_key), usage_key=urllib.parse.quote(str(xblock.location)) ) elif category == 'library': library_key = xblock.location.course_key return reverse_library_url('library_handler', library_key) else: return reverse_usage_url('container_handler', xblock.location) def xblock_lms_url(xblock) -> str: """ Returns the LMS URL for the specified xblock. Args: xblock: The xblock to get the LMS URL for. Returns: str: The LMS URL for the specified xblock. """ lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) return f"{lms_root_url}/courses/{xblock.location.course_key}/jump_to/{xblock.location}" def xblock_embed_lms_url(xblock) -> str: """ Returns the LMS URL for the specified xblock in embed mode. Args: xblock: The xblock to get the LMS URL for. Returns: str: The LMS URL for the specified xblock in embed mode. """ lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) return f"{lms_root_url}/xblock/{xblock.location}" def xblock_type_display_name(xblock, default_display_name=None): """ Returns the display name for the specified type of xblock. Note that an instance can be passed in for context dependent names, e.g. a vertical beneath a sequential is a Unit. :param xblock: An xblock instance or the type of xblock (as a string). :param default_display_name: The default value to return if no display name can be found. :return: """ if hasattr(xblock, 'category'): category = xblock.category if category == 'vertical' and not is_unit(xblock): return _('Vertical') else: category = xblock if category == 'chapter': return _('Section') elif category == 'sequential': return _('Subsection') elif category == 'vertical': return _('Unit') elif category == 'problem': # The problem XBlock's display_name.default is not helpful ("Blank Problem") but changing it could have # too many ripple effects in other places, so we have a special case for capa problems here. # Note: With a ProblemBlock instance, we could actually check block.problem_types to give a more specific # description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type # string ("problem"). return _('Problem') component_class = XBlock.load_class(category) if hasattr(component_class, 'display_name') and component_class.display_name.default: return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string else: return default_display_name def xblock_primary_child_category(xblock): """ Returns the primary child category for the specified xblock, or None if there is not a primary category. """ category = xblock.category if category == 'course': return 'chapter' elif category == 'chapter': return 'sequential' elif category == 'sequential': return 'vertical' return None def remove_entrance_exam_graders(course_key, user): """ Removes existing entrance exam graders attached to the specified course Typically used when adding/removing an entrance exam. """ grading_model = CourseGradingModel.fetch(course_key) graders = grading_model.graders for i, grader in enumerate(graders): if grader['type'] == GRADER_TYPES['ENTRANCE_EXAM']: CourseGradingModel.delete_grader(course_key, i, user) class ImportIdGenerator(IdGenerator): """ Modulestore's IdGenerator doesn't work for importing single blocks as OLX, so we implement our own """ def __init__(self, context_key): super().__init__() self.context_key = context_key def create_aside(self, definition_id, usage_id, aside_type): """ Generate a new aside key """ raise NotImplementedError() def create_usage(self, def_id) -> UsageKey: """ Generate a new UsageKey for an XBlock """ # Note: Split modulestore will detect this temporary ID and create a new block ID when the XBlock is saved. return self.context_key.make_usage_key(def_id.block_type, LocalId()) def create_definition(self, block_type, slug=None) -> DefinitionLocator: """ Generate a new definition_id for an XBlock """ # Note: Split modulestore will detect this temporary ID and create a new definition ID when the XBlock is saved. return DefinitionLocator(block_type, LocalId(block_type)) @frozen class StaticFileNotices: """ Information about what static files were updated (or not) when pasting content into another course """ new_files: list[str] = Factory(list) conflicting_files: list[str] = Factory(list) error_files: list[str] = Factory(list) def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tuple[XBlock | None, StaticFileNotices]: """ Import a block (along with its children and any required static assets) from the "staged" OLX in the user's clipboard. Does not deal with permissions or REST stuff - do that before calling this. Returns (1) the newly created block on success or None if the clipboard is empty, and (2) a summary of changes made to static files in the destination course. """ from cms.djangoapps.contentstore.views.preview import _load_preview_block if not content_staging_api: raise RuntimeError("The required content_staging app is not installed") user_clipboard = content_staging_api.get_user_clipboard(request.user.id) if not user_clipboard: # Clipboard is empty or expired/error/loading return None, StaticFileNotices() olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id) static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id) node = etree.fromstring(olx_str) store = modulestore() with store.bulk_operations(parent_key.course_key): parent_descriptor = store.get_item(parent_key) # Some blocks like drag-and-drop only work here with the full XBlock runtime loaded: parent_xblock = _load_preview_block(request, parent_descriptor) new_xblock = _import_xml_node_to_parent( node, parent_xblock, store, user_id=request.user.id, slug_hint=user_clipboard.source_usage_key.block_id, copied_from_block=str(user_clipboard.source_usage_key), tags=user_clipboard.content.tags, ) # Now handle static files that need to go into Files & Uploads: notices = _import_files_into_course( course_key=parent_key.context_key, staged_content_id=user_clipboard.content.id, static_files=static_files, ) return new_xblock, notices def _import_xml_node_to_parent( node, parent_xblock: XBlock, # The modulestore we're using store, # The ID of the user who is performing this operation user_id: int, # Hint to use as usage ID (block_id) for the new XBlock slug_hint: str | None = None, # UsageKey of the XBlock that this one is a copy of copied_from_block: str | None = None, # Content tags applied to the source XBlock(s) tags: dict[str, str] | None = None, ) -> XBlock: """ Given an XML node representing a serialized XBlock (OLX), import it into modulestore 'store' as a child of the specified parent block. Recursively copy children as needed. """ runtime = parent_xblock.runtime parent_key = parent_xblock.scope_ids.usage_id block_type = node.tag # Modulestore's IdGenerator here is SplitMongoIdManager which is assigned # by CachingDescriptorSystem Runtime and since we need our custom ImportIdGenerator # here we are temporaraliy swtiching it. original_id_generator = runtime.id_generator # Generate the new ID: runtime.id_generator = ImportIdGenerator(parent_key.context_key) def_id = runtime.id_generator.create_definition(block_type, slug_hint) usage_id = runtime.id_generator.create_usage(def_id) keys = ScopeIds(None, block_type, def_id, usage_id) # parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either # one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores # 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'. # For children of this block, obviously only id_generator is used. xblock_class = runtime.load_block_type(block_type) # Note: if we find a case where any XBlock needs access to the block-specific static files that were saved to # export_fs during copying, we could make them available here via runtime.resources_fs before calling parse_xml. # However, currently the only known case for that is video block's transcript files, and those will # automatically be "carried over" to the new XBlock even in a different course because the video ID is the same, # and VAL will thus make the transcript available. child_nodes = [] if issubclass(xblock_class, XmlMixin): # Hack: XBlocks that use "XmlMixin" have their own XML parsing behavior, and in particular if they encounter # an XML node that has no children and has only a "url_name" attribute, they'll try to load the XML data # from an XML file in runtime.resources_fs. But that file doesn't exist here. So we set at least one # additional attribute here to make sure that url_name is not the only attribute; otherwise in some cases, # XmlMixin.parse_xml will try to load an XML file that doesn't exist, giving an error. The name and value # of this attribute don't matter and should be ignored. node.attrib["x-is-pointer-node"] = "no" if not xblock_class.has_children: # No children to worry about. The XML may contain child nodes, but they're not XBlocks. temp_xblock = xblock_class.parse_xml(node, runtime, keys) else: # We have to handle the children ourselves, because there are lots of complex interactions between # * the vanilla XBlock parse_xml() method, and its lack of API for "create and save a new XBlock" # * the XmlMixin version of parse_xml() which only works with ImportSystem, not modulestore or the v2 runtime # * the modulestore APIs for creating and saving a new XBlock, which work but don't support XML parsing. # We can safely assume that if the XBLock class supports children, every child node will be the XML # serialization of a child block, in order. For blocks that don't support children, their XML content/nodes # could be anything (e.g. HTML, capa) node_without_children = etree.Element(node.tag, **node.attrib) temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys) child_nodes = list(node) # Restore the original id_generator runtime.id_generator = original_id_generator if xblock_class.has_children and temp_xblock.children: raise NotImplementedError("We don't yet support pasting XBlocks with children") temp_xblock.parent = parent_key if copied_from_block: # Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin) temp_xblock.copied_from_block = copied_from_block # Save the XBlock into modulestore. We need to save the block and its parent for this to work: new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True) parent_xblock.children.append(new_xblock.location) store.update_item(parent_xblock, user_id) children_handled = False if hasattr(new_xblock, 'studio_post_paste'): # Allow an XBlock to do anything fancy it may need to when pasted from the clipboard. # These blocks may handle their own children or parenting if needed. Let them return booleans to # let us know if we need to handle these or not. children_handed = new_xblock.studio_post_paste(store, node) if not children_handled: for child_node in child_nodes: child_copied_from = _get_usage_key_from_node(child_node, copied_from_block) if copied_from_block else None _import_xml_node_to_parent( child_node, new_xblock, store, user_id=user_id, copied_from_block=str(child_copied_from), tags=tags, ) # Copy content tags to the new xblock if copied_from_block and tags: object_tags = tags.get(str(copied_from_block)) if object_tags: content_tagging_api.set_all_object_tags( content_key=new_xblock.location, object_tags=object_tags, ) return new_xblock def _import_files_into_course( course_key: CourseKey, staged_content_id: int, static_files: list[content_staging_api.StagedContentFileData], ) -> StaticFileNotices: """ For the given staged static asset files (which are in "Staged Content" such as the user's clipbaord, but which need to end up in the course's Files & Uploads page), import them into the destination course, unless they already exist. """ # List of files that were newly added to the destination course new_files = [] # List of files that conflicted with identically named files already in the destination course conflicting_files = [] # List of files that had an error (shouldn't happen unless we have some kind of bug) error_files = [] for file_data_obj in static_files: if not isinstance(file_data_obj.source_key, AssetKey): # This static asset was managed by the XBlock and instead of being added to "Files & Uploads", it is stored # using some other system. We could make it available via runtime.resources_fs during XML parsing, but it's # not needed here. continue # At this point, we know this is a "Files & Uploads" asset that we may need to copy into the course: try: result = _import_file_into_course(course_key, staged_content_id, file_data_obj) if result is True: new_files.append(file_data_obj.filename) elif result is None: pass # This file already exists; no action needed. else: conflicting_files.append(file_data_obj.filename) except Exception: # lint-amnesty, pylint: disable=broad-except error_files.append(file_data_obj.filename) log.exception(f"Failed to import Files & Uploads file {file_data_obj.filename}") return StaticFileNotices( new_files=new_files, conflicting_files=conflicting_files, error_files=error_files, ) def _import_file_into_course( course_key: CourseKey, staged_content_id: int, file_data_obj: content_staging_api.StagedContentFileData, ) -> bool | None: """ Import a single staged static asset file into the course, unless it already exists. Returns True if it was imported, False if there's a conflict, or None if the file already existed (no action needed). """ filename = file_data_obj.filename new_key = course_key.make_asset_key("asset", filename) try: current_file = contentstore().find(new_key) except NotFoundError: current_file = None if not current_file: # This static asset should be imported into the new course: content_type = guess_type(filename)[0] data = content_staging_api.get_staged_content_static_file_data(staged_content_id, filename) if data is None: raise NotFoundError(file_data_obj.source_key) content = StaticContent(new_key, name=filename, content_type=content_type, data=data) # If it's an image file, also generate the thumbnail: thumbnail_content, thumbnail_location = contentstore().generate_thumbnail(content) if thumbnail_content is not None: content.thumbnail_location = thumbnail_location contentstore().save(content) return True elif current_file.content_digest == file_data_obj.md5_hash: # The file already exists and matches exactly, so no action is needed return None else: # There is a conflict with some other file that has the same name. return False def is_item_in_course_tree(item): """ Check that the item is in the course tree. It's possible that the item is not in the course tree if its parent has been deleted and is now an orphan. """ ancestor = item.get_parent() while ancestor is not None and ancestor.location.block_type != "course": ancestor = ancestor.get_parent() return ancestor is not None def _get_usage_key_from_node(node, parent_id: str) -> UsageKey | None: """ Returns the UsageKey for the given node and parent ID. If the parent_id is not a valid UsageKey, or there's no "url_name" attribute in the node, then will return None. """ parent_key = UsageKey.from_string(parent_id) parent_context = parent_key.context_key usage_key = None block_id = node.attrib.get("url_name") block_type = node.tag if parent_context and block_id and block_type: usage_key = parent_context.make_usage_key( block_type=block_type, block_id=block_id, ) return usage_key