From 3bd5ef28342351912ac460aee39035ccaae5348f Mon Sep 17 00:00:00 2001 From: "Kyle D. McCormick" Date: Fri, 28 Jun 2024 10:03:41 -0400 Subject: [PATCH] feat: UpstreamSyncMixin --- cms/djangoapps/contentstore/helpers.py | 8 +- cms/envs/common.py | 2 + .../core/djangoapps/content_libraries/sync.py | 188 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 openedx/core/djangoapps/content_libraries/sync.py diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index a4ece6c85d59..916a623bd2a5 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -23,6 +23,7 @@ from cms.djangoapps.models.settings.course_grading import CourseGradingModel from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.content_libraries.sync import is_block_valid_upstream import openedx.core.djangoapps.content_staging.api as content_staging_api import openedx.core.djangoapps.content_tagging.api as content_tagging_api @@ -293,7 +294,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> staged_content_id=user_clipboard.content.id, static_files=static_files, ) - return new_xblock, notices @@ -375,6 +375,12 @@ def _import_xml_node_to_parent( 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 + copied_from_key = UsageKey.from_string(copied_from_block) + if is_block_valid_upstream(copied_from_key): + upstream_link_requested = lambda: True ## @@TODO ask user + if upstream_link_requested(): + temp_xblock.set_upstream(copied_from_key, user_id) + # 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) diff --git a/cms/envs/common.py b/cms/envs/common.py index e2ad3e239f61..cb9e7704526c 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -128,6 +128,7 @@ from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin from cms.lib.xblock.authoring_mixin import AuthoringMixin from xmodule.modulestore.edit_info import EditInfoMixin +from openedx.core.djangoapps.content_libraries.sync import UpstreamSyncMixin from openedx.core.djangoapps.theming.helpers_dirs import ( get_themes_unchecked, get_theme_base_dirs_from_settings @@ -1019,6 +1020,7 @@ XModuleMixin, EditInfoMixin, AuthoringMixin, + UpstreamSyncMixin, ) # .. setting_name: XBLOCK_EXTRA_MIXINS diff --git a/openedx/core/djangoapps/content_libraries/sync.py b/openedx/core/djangoapps/content_libraries/sync.py new file mode 100644 index 000000000000..d8317fbdce14 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/sync.py @@ -0,0 +1,188 @@ +""" +Synchronize content and settings from upstream blocks (in content libraries) to their +downstream usages (in courses, etc.) + +At the time of writing, upstream blocks are assumed to come from content libraries. +However, the XBlock fields are designed to be agnostic to their upstream's source context, +so this assumption could be relaxed in the future if there is a need for upstreams from +other kinds of learning contexts. +""" +from django.contrib.auth import get_user_model + +from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import LibraryUsageLocatorV2 +from xblock.fields import String, Integer, List, Dict + +from openedx.core.djangoapps.content_libraries.api import get_library_block +from openedx.core.djangoapps.xblock.api import load_block + + +class UpstreamSyncMixin(XBlockMixin): + """ + @@TODO docstring + """ + + upstream = String( + scope=Scope.settings, + help=( + "The usage key of a block (generally within a Content Library) which serves as a source of upstream " + "updates for this block, or None if there is no such upstream. Please note: It is valid for upstream_block " + "to hold a usage key for a block that does not exist (or does not *yet* exist) on this instance, " + "particularly if this block was imported from a different instance." + ), + hidden=True, + default=None, + enforce_type=True, + ) + upstream_version = Integer( + scope=Scope.settings, + help=( + "The upstream_block's version number, at the time this block was created from it. " + "If this version is older than the upstream_block's latest version, then CMS will " + "allow this block to fetch updated content from upstream_block." + ), + hidden=True, + default=None, + enforce_type=True, + ) + upstream_overridden = List( + scope=Scope.settings, + help=( + "@@TODO helptext" + ), + hidden=True, + default=[], + enforce_type=True, + ) + upstream_settings = Dict( + scope=Scope.settings, + help=( + "@@TODO helptext" + ), + hidden=True, + default={}, + enforce_type=True, + ) + + def save(self, *args, **kwargs): + """ + @@TODO + """ + for field_name, value in self.upstream_settings.items(): + if field_name not in self.upstream_overridden: + if value != getattr(self, field_name): + self.upstream_overridden.append(field_name) + super().save() + + def set_upstream(self, upstream_key: LibraryUsageLocatorV2, user_id: int) -> None: + """ + Assign an upstream to this block and fetch upstream settings. + + Does not save block; caller must do so. + """ + self.upstream = str(upstream_key) + try: + self._sync_with_upstream(user_id=user_id, apply_updates=False) + except exc: + pass # @@TODO + + @XBlock.json_handler + def upstream_info(self, _data=None, _suffix=None): + """ + @@TODO write this + """ + try: + upstream_block, upstream_meta = self._load_upstream(user_id) + except exc: + pass # @@TODO + return { + "upstream": self.upstream, + "available_version": upstream_meta.version_num, + "available_version": ..., + ... update info ... + } + + @XBlock.handler + def update_from_upstream(self, request=None, suffix=None): + """ + @@TODO docstring + """ + try: + user_id = requester.user.id if request and request.user else 0 + self._sync_with_upstream(user_id=user_id, apply_updates=True) + except NoUpsream: + return Http204() # @@TODO what's the right response here? + except BadUpstream as exc: + return Http400BadRequest(exc.message) + self.save() + + def _sync_with_upstream(self, *, user_id: int, apply_updates: bool) -> None: + """ + @@TODO docstring + + Does not save block; caller must do so. + + Can raise NoUpstream or BadUpstream. + """ + upstream, upstream_meta = self._load_upstream(user_id) + self.upstream_settings = {} + self.upstream_version = upstream_meta.version_num + for field_name, field in upstream.fields.items(): + if field.scope not in [Scope.settings, Scope.content]: + continue + value = getattr(upstream, field_name) + if field.scope == Scope.settings: + self.upstream_settings[field_name] = value + if field_name in self.upstream_overridden: + continue + if not apply_updates: + continue + setattr(self, field_name, value) + + def _load_upstream(self, user_id: int) -> tuple[XBlock, LibraryXBlockMetadata]: + """ + @@TODO + + Can raise NoUpstream or BadUpstream. + """ + if not self.upstream: + raise NoUpstream(self.usage_key) + if not isinstance(self.upstream, str): + raise BadUpstream(self.usage_key, self.upstream, "not a string") + try: + upstream_key = LibraryUsageLocatorV2.from_string(self.upstream) + upstream = load_block(upstream_key, get_user_model().objects.get(id=user_id)) + upstream_meta = get_library_block(upstream_key) + return upstream, upstream_meta + except InvalidKeyError: + raise BadUpstream(self, "not a valid block usage key") + except NotFound: + raise BadUpstream(self, "block not found") + except ContentLibraryBlockNotFound: + raise BadUpstream(self, "block metadata not found") + + +class NoUpstream(Exception): + """ + @@TODO + """ + def __init__(self, downstream_key: UpstreamSyncMixin): + super().__init__(f"Block '{downstream_key}' is not associated with an upstream") + + +class BadUpstream(Exception): + """ + @@TODO + """ + def __init__(self, downstream_key: Usagekey, upstream_key: object, message: str): + super().__init__( + f"Cannot fetch updates for block '{downstream_key}' " + f"from upstream '{upstream_key}': {message}" + ) + + +def is_block_valid_upstream(usage_key: UsageKey) -> bool: + """ + @@TODO move + """ + return isinstance(usage_key, LibraryUsageLocatorV2)