Skip to content

Commit

Permalink
feat: UpstreamSyncMixin
Browse files Browse the repository at this point in the history
  • Loading branch information
kdmccormick committed Jul 26, 2024
1 parent a6aaaa6 commit df8265d
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 3 deletions.
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_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_valid_upstream(copied_from_key):
upstream_link_requested = lambda: True # @@TODO ask user
if upstream_link_requested():
temp_xblock.assign_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
1 change: 1 addition & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,7 @@
XModuleMixin,
EditInfoMixin,
AuthoringMixin,
"openedx.core.djangoapps.content_libraries.sync.UpstreamSyncMixin",
)

# .. setting_name: XBLOCK_EXTRA_MIXINS
Expand Down
228 changes: 228 additions & 0 deletions openedx/core/djangoapps/content_libraries/sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"""
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.
"""
import json

from django.contrib.auth import get_user_model
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import LibraryUsageLocatorV2
from xblock.fields import Scope, String, Integer, List, Dict
from xblock.core import XBlockMixin, XBlock
from webob import Request, Response

from openedx.core.djangoapps.content_libraries.api import (
get_library_block,
LibraryXBlockMetadata,
ContentLibraryBlockNotFound,
)
from openedx.core.djangoapps.xblock.api import load_block, NotFound as XBlockNotFound


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 docstring
@@TODO use is_dirty instead of getattr for efficiency?
"""
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 assign_upstream(self, upstream_key: LibraryUsageLocatorV2) -> None:
"""
Assign an upstream to this block and fetch upstream settings.
Does not save block; caller must do so.
@@TODO params
"""
old_upstream = self.upstream
self.upstream = str(upstream_key)
try:
self._sync_with_upstream(apply_updates=False)
except BadUpstream:
self.upstream = old_upstream
raise
self.save()

@XBlock.handler
def upstream_link(self, request: Request, _suffix=None) -> Response:
"""
@@TODO docstring
@@TODO more data?
"""
# @@TODO: There *has* to be a way to load a learning core block without invoking the user service...
if request.method == "GET":
try:
upstream_meta = self.get_upstream_meta()
except BadUpstream as exc:
return Response(str(exc), status_code=400)
return Response(
json.dumps(
{
"usage_key": self.upstream,
"version_current": self.upstream_version,
"version_latest": upstream_meta.version_num if upstream_meta else None,
},
indent=4,
),
)
if request.method == "PUT":
# @@TODO better validation
try:
self.assign_upstream(UsageKey.from_string(json.loads(request.data["usage_key"])))
except BadUpstream as exc:
return Response(str(exc), status_code=400)
return Response(status_code=204) # @@TODO what to returN?
return Response(status_code=405)

@XBlock.handler
def update_from_upstream(self, request: Request, suffix=None) -> Response:
"""
@@TODO docstring
"""
if request.method != "POST":
return Response(status_code=405)
try:
self._sync_with_upstream(apply_updates=True)
except BadUpstream as exc:
return Response(str(exc), status_code=400)
self.save()
return Response(status_code=204)

def _sync_with_upstream(self, *, 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()
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 get_upstream_meta(self) -> LibraryXBlockMetadata:
"""
@@TODO docstring
@@TODO _load_upstream should call this, not the other way around
"""
_, upstream_meta = self._load_upstream(load_block=False)
return upstream_meta

def _load_upstream(self, load_block: bool = True) -> tuple[XBlock | None, LibraryXBlockMetadata]:
"""
This this block's upstream from a content library.
Raises BadUpstream if the upstream block could not be loaded for any reason.
"""
cannot_load = f"Cannot load updates for component at '{self.usage_key}'"
if not self.upstream:
raise BadUpstream(f"{cannot_load}: no linked content library item")
try:
print(self.upstream)
upstream_key = LibraryUsageLocatorV2.from_string(self.upstream)
except InvalidKeyError as exc:
raise BadUpstream(
f"{cannot_load}: invalid content library item reference '{self.upstream}'"
) from exc
try:
upstream_meta = get_library_block(upstream_key)
except ContentLibraryBlockNotFound as exc:
raise BadUpstream(
f"{cannot_load}: linked item '{upstream_key}' does not belong to a content library"
) from exc
if load_block: # @@TODO this is a hack
user_id = self.runtime.service(self, "user")._django_user.id
try:
upstream = load_block(upstream_key, get_user_model().objects.get(id=user_id))
except XBlockNotFound as exc:
raise BadUpstream(
f"{cannot_load}: failed to load linked content library item at '{upstream_key}'. "
"Either the item was deleted, or you lack permission to view its contents."
) from exc
else:
upstream = None
return upstream, upstream_meta


class BadUpstream(Exception):
"""
Base exception for any content-level problems we can hit while loading a block's upstream.
Should not represent unexpected internal server errors.
May appear in API responses, so they should be somewhat user friendly and avoid sensitive info.
"""


def is_valid_upstream(usage_key: UsageKey) -> bool:
"""
@@TODO docstring
"""
return isinstance(usage_key, LibraryUsageLocatorV2)
16 changes: 14 additions & 2 deletions xmodule/capa_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,10 +486,22 @@ def display_name_with_default(self):
Default to the display_name if it isn't None or not an empty string,
else fall back to problem category.
"""
# @@TODO: temporary suffix code
from openedx.core.djangoapps.content_libraries.sync import BadUpstream
try:
upstream_meta = self.get_upstream_meta()
except BadUpstream:
suffix = ""
else:
latest = upstream_meta.version_num
suffix = f" [v{self.upstream_version}]"
if self.upstream_version < latest:
suffix += f" [UPDATE AVAILBLE: v{latest}]"

if self.display_name is None or not self.display_name.strip():
return self.location.block_type
return self.location.block_type + suffix

return self.display_name
return self.display_name + suffix

def grading_method_display_name(self) -> str | None:
"""
Expand Down

0 comments on commit df8265d

Please sign in to comment.