Skip to content

Commit

Permalink
feat: UpstreamSyncMixin
Browse files Browse the repository at this point in the history
  • Loading branch information
kdmccormick committed Jun 28, 2024
1 parent 5e1ca9c commit 3bd5ef2
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 1 deletion.
8 changes: 7 additions & 1 deletion cms/djangoapps/contentstore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1019,6 +1020,7 @@
XModuleMixin,
EditInfoMixin,
AuthoringMixin,
UpstreamSyncMixin,
)

# .. setting_name: XBLOCK_EXTRA_MIXINS
Expand Down
188 changes: 188 additions & 0 deletions openedx/core/djangoapps/content_libraries/sync.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 3bd5ef2

Please sign in to comment.