-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat: Configured subsection prerequisites persist during course olx/xml export/import #37475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,12 +44,14 @@ | |
|
|
||
| from common.djangoapps.util.monitoring import monitor_import_failure | ||
| from openedx.core.djangoapps.content_tagging.api import import_course_tags_from_csv | ||
| from openedx.core.lib.gating import api as gating_api | ||
| from openedx.core.lib.gating.exceptions import GatingValidationError | ||
| from xmodule.assetstore import AssetMetadata | ||
| from xmodule.contentstore.content import StaticContent | ||
| from xmodule.errortracker import make_error_tracker | ||
| from xmodule.modulestore import ModuleStoreEnum | ||
| from xmodule.modulestore.django import ASSET_IGNORE_REGEX | ||
| from xmodule.modulestore.exceptions import DuplicateCourseError | ||
| from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError | ||
| from xmodule.modulestore.mongo.base import MongoRevisionKey | ||
| from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots | ||
| from xmodule.modulestore.xml import LibraryXMLModuleStore, XMLImportingModuleStoreRuntime, XMLModuleStore | ||
|
|
@@ -297,6 +299,10 @@ def __init__( | |
| ) | ||
| self.logger, self.errors = make_error_tracker() | ||
|
|
||
| # Here we introduce an approach where we collect all gating relationship information in a course | ||
| # and persist at the end. For this we have introduced a new gating_relationships array property. | ||
| self.gating_relationships = [] | ||
|
|
||
| def preflight(self): | ||
| """ | ||
| Perform any pre-import sanity checks. | ||
|
|
@@ -437,6 +443,7 @@ def import_courselike(self, runtime, courselike_key, dest_id, source_courselike) | |
| dest_id, | ||
| do_import_static=self.do_import_static, | ||
| runtime=runtime, | ||
| gating_relationships=self.gating_relationships, | ||
| ) | ||
| self.static_updater(course, source_courselike, courselike_key, dest_id, runtime) | ||
| self.store.update_item(course, self.user_id) | ||
|
|
@@ -519,6 +526,7 @@ def depth_first(subtree): | |
| dest_id, | ||
| do_import_static=self.do_import_static, | ||
| runtime=courselike.runtime, | ||
| gating_relationships=self.gating_relationships, | ||
| ) | ||
| except Exception: | ||
| log.exception( | ||
|
|
@@ -543,6 +551,7 @@ def depth_first(subtree): | |
| dest_id, | ||
| do_import_static=self.do_import_static, | ||
| runtime=courselike.runtime, | ||
| gating_relationships=self.gating_relationships, | ||
| ) | ||
| except Exception: | ||
| log.exception( | ||
|
|
@@ -567,6 +576,10 @@ def run_imports(self): | |
| except DuplicateCourseError: | ||
| continue | ||
|
|
||
| # Prerequisite records are specific to a course, therefore, we need to reset | ||
| # the relationship information that has been collected on the previous course. | ||
| self.gating_relationships = [] | ||
|
|
||
| # This bulk operation wraps all the operations to populate the published branch. | ||
| with self.store.bulk_operations(dest_id): | ||
| # Retrieve the course itself. | ||
|
|
@@ -597,6 +610,15 @@ def run_imports(self): | |
| logging.info(f'Course import {dest_id}: No tags.csv file present.') | ||
| except ValueError as e: | ||
| logging.info(f'Course import {dest_id}: {str(e)}') | ||
|
|
||
| # After collecting all gating relationships, here we will try to persist | ||
| # the relationship records ensuring all validation rules are applied. | ||
| # If the there is invalid information, we can take measures (skip registering | ||
| # the relationship.) | ||
| if self.gating_relationships: | ||
| _apply_gating_relationships(self.store, dest_id, self.gating_relationships) | ||
| self.gating_relationships = [] | ||
|
|
||
| self.post_course_import(dest_id) | ||
| yield courselike | ||
|
|
||
|
|
@@ -711,7 +733,8 @@ def import_drafts(self, courselike, courselike_key, data_path, dest_id): | |
| data_path, | ||
| courselike_key, | ||
| dest_id, | ||
| courselike.runtime | ||
| courselike.runtime, | ||
| self.gating_relationships, | ||
| ) | ||
|
|
||
| # Importing the drafts potentially triggered a new structure version. | ||
|
|
@@ -834,10 +857,86 @@ def import_library_from_xml(*args, **kwargs): | |
| return list(manager.run_imports()) | ||
|
|
||
|
|
||
| def _process_sequential_prerequisites(block): | ||
| """ | ||
| Extracts sequential prerequisite information, does basic validation and | ||
| returns the relationship information as a tuple. | ||
|
|
||
| Args: | ||
| block: The sequential block | ||
| """ | ||
| if not hasattr(block, "xml_attributes") or not block.xml_attributes: | ||
| return None | ||
|
|
||
| try: | ||
| required_content = block.xml_attributes.get('required_content') | ||
| if not isinstance(required_content, str): | ||
| return None | ||
|
|
||
| min_score = block.xml_attributes.get('min_score') | ||
| min_completion = block.xml_attributes.get('min_completion') | ||
| if not min_score or not min_completion: | ||
| return None | ||
|
|
||
| min_score = int(min_score) | ||
| min_completion = int(min_completion) | ||
|
Comment on lines
+881
to
+882
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This (+catching the exceptions) is redundant. |
||
|
|
||
| except (ValueError, TypeError) as e: | ||
| logging.debug('Failed to extract valid required_content, min_score, and/or min_completion: %s', e) | ||
| return None | ||
|
|
||
| course_key = block.location.course_key | ||
| prerequisite_usage_key = course_key.make_usage_key('sequential', required_content) | ||
|
|
||
| return (block.location, prerequisite_usage_key, min_score, min_completion) | ||
|
|
||
|
|
||
| def _apply_gating_relationships(store, course_key, gating_relationships): | ||
| """ | ||
| Validates that each prerequisite block exists before calling the gating API to persist the gating relationship, | ||
| logs errors and ignores invalid gating relationship. | ||
|
|
||
| Args: | ||
| store: modulestore | ||
| course_key: Course key | ||
| gating_relationships: List of tuples (block_location, prereq_usage_key, min_score, min_completion) | ||
| """ | ||
| for block_location, prereq_usage_key, min_score, min_completion in gating_relationships: | ||
| # Here we validate and store records that are valid, in this case validate if | ||
| # sequantial with the specified ID exists. | ||
| try: | ||
| store.get_item(prereq_usage_key) # raises ItemNotFoundError if missing | ||
| except ItemNotFoundError: | ||
| logging.error( | ||
| "Prerequisite block '%s' referenced by '%s' does not exist.", | ||
| prereq_usage_key, block_location | ||
| ) | ||
| continue | ||
|
|
||
| try: | ||
| gating_api.add_prerequisite(course_key, prereq_usage_key) | ||
| gating_api.set_required_content( | ||
| course_key, | ||
| block_location, | ||
| prereq_usage_key, | ||
| min_score, | ||
| min_completion | ||
| ) | ||
| except GatingValidationError as e: | ||
| logging.error( | ||
| "Error invalid block %s with prerequisite %s", | ||
| block_location, prereq_usage_key | ||
| ) | ||
| logging.error( | ||
| "Error : %s", | ||
| e | ||
| ) | ||
|
|
||
|
|
||
| def _update_and_import_block( # pylint: disable=too-many-statements | ||
| block, store, user_id, | ||
| source_course_id, dest_course_id, | ||
| do_import_static=True, runtime=None): | ||
| do_import_static=True, runtime=None, gating_relationships=None): | ||
| """ | ||
| Update all the block reference fields to the destination course id, | ||
| then import the block into the destination course. | ||
|
|
@@ -918,6 +1017,15 @@ def _convert_ref_fields_to_new_namespace(reference): | |
| block.location.block_id, fields, runtime, asides=asides | ||
| ) | ||
|
|
||
| # As the order of course blocks is unpredictable during import, we only collect sequantials | ||
| # and store the gating relationship at the end. This is to enfirce validation and | ||
| # persist relationship if only the referenced sequentials exist in our database. | ||
| # We do this only if the current block is sequential and gating_relationships is iitialized. | ||
| if block.location.block_type == "sequential" and gating_relationships is not None: | ||
| gating_relationship_data = _process_sequential_prerequisites(block) | ||
| if gating_relationship_data: | ||
| gating_relationships.append(gating_relationship_data) | ||
|
|
||
| # TODO: Move this code once the following condition is met. | ||
| # Get to the point where XML import is happening inside the | ||
| # modulestore that is eventually going to store the data. | ||
|
|
@@ -980,7 +1088,8 @@ def _import_course_draft( | |
| course_data_path, | ||
| source_course_id, | ||
| target_id, | ||
| mongo_runtime | ||
| mongo_runtime, | ||
| gating_relationships=None | ||
| ): | ||
| """ | ||
| This method will import all the content inside of the 'drafts' folder, if content exists. | ||
|
|
@@ -1050,6 +1159,7 @@ def _import_block(block): | |
| source_course_id, | ||
| target_id, | ||
| runtime=mongo_runtime, | ||
| gating_relationships=gating_relationships, | ||
| ) | ||
| for child in block.get_children(): | ||
| _import_block(child) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -969,8 +969,27 @@ def definition_to_xml(self, resource_fs): | |
| xml_object = etree.Element('sequential') | ||
| for child in self.get_children(): | ||
| self.runtime.add_block_as_child_node(child, xml_object) | ||
| self.add_prerequisite_to_xml(xml_object) | ||
| return xml_object | ||
|
|
||
| def add_prerequisite_to_xml(self, xml_object): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The exports do not contain the information whether a subsection is available as a prerequisite: https://github.com/openedx/edx-platform/blob/d6633243a8397bb56eb21e38cc43efada4f05931/cms/templates/js/access-editor.underscore#L43-L55
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| """ | ||
| Add prerequisite information to sequential XML for export. | ||
| """ | ||
| from openedx.core.lib.gating import api as gating_api | ||
|
|
||
| prereq_info = gating_api.get_required_content( | ||
| self.location.course_key, | ||
| self.location | ||
| ) | ||
| prereq_usage_key_str, min_score, min_completion = prereq_info | ||
| if not isinstance(prereq_usage_key_str, str): | ||
| return | ||
| prereq_usage_key = UsageKey.from_string(prereq_usage_key_str) | ||
|
Comment on lines
+985
to
+988
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @haftamuk, I did not test this one, but wouldn't it be sufficient to add |
||
| xml_object.set('required_content', prereq_usage_key.block_id) | ||
| xml_object.set('min_score', str(min_score)) | ||
| xml_object.set('min_completion', str(min_completion)) | ||
|
|
||
| @property | ||
| def non_editable_metadata_fields(self): | ||
| """ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.