Skip to content

Commit

Permalink
feat: UpstreamSyncMixin
Browse files Browse the repository at this point in the history
  • Loading branch information
kdmccormick committed Aug 21, 2024
1 parent cffd1e6 commit 28873db
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 10 deletions.
31 changes: 21 additions & 10 deletions cms/djangoapps/contentstore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from attrs import frozen, Factory
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from opaque_keys.edx.keys import AssetKey, CourseKey, UsageKey
from opaque_keys.edx.locator import DefinitionLocator, LocalId
Expand All @@ -22,15 +23,19 @@
from xmodule.xml_block import XmlMixin

from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.upstream_sync import validate_upstream_key, UnsupportedUpstreamKeyType
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

from .utils import reverse_course_url, reverse_library_url, reverse_usage_url

log = logging.getLogger(__name__)


User = get_user_model()


# 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.
Expand Down Expand Up @@ -285,7 +290,7 @@ def import_staged_content_from_user_clipboard(
node,
parent_xblock,
store,
user_id=request.user.id,
user=request.user,
slug_hint=user_clipboard.source_usage_key.block_id,
copied_from_block=str(user_clipboard.source_usage_key),
tags=user_clipboard.content.tags,
Expand All @@ -305,8 +310,8 @@ def _import_xml_node_to_parent(
parent_xblock: XBlock,
# The modulestore we're using
store,
# The ID of the user who is performing this operation
user_id: int,
# The user who is performing this operation
user: User,
# 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
Expand Down Expand Up @@ -380,14 +385,20 @@ 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 link_to_upstream and is_valid_upstream(copied_from_key):
temp_xblock.assign_upstream(copied_from_key, user_id)
if copied_from_block and link_to_upstream:
# If requested, link this block as a downstream of where it was copied from
try:
validate_upstream_key(copied_from_block)
except UnsupportedUpstreamKeyType:
pass # @@TODO - should we let this error bubble up?
else:
temp_xblock.upstream = copied_from_block
temp_xblock.sync_from_upstream(user=user, apply_updates=False)

# 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)
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)
store.update_item(parent_xblock, user.id)

children_handled = False
if hasattr(new_xblock, 'studio_post_paste'):
Expand All @@ -403,7 +414,7 @@ def _import_xml_node_to_parent(
child_node,
new_xblock,
store,
user_id=user_id,
user=user,
copied_from_block=str(child_copied_from),
tags=tags,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ def get_assets_url(self, obj):
return None


class UpstreamInfoSerializer(serializers.Serializer):
"""
Serializer holding info for syncing a block with its upstream (eg, a library block).
"""
usage_key = serializers.CharField()
current_version = serializers.IntegerField(allow_null=True)
latest_version = serializers.IntegerField(allow_null=True)
sync_url = serializers.CharField()
error = serializers.CharField(allow_null=True)
sync_available = serializers.BooleanField()


class ChildVerticalContainerSerializer(serializers.Serializer):
"""
Serializer for representing a xblock child of vertical container.
Expand All @@ -113,6 +125,7 @@ class ChildVerticalContainerSerializer(serializers.Serializer):
block_type = serializers.CharField()
user_partition_info = serializers.DictField()
user_partitions = serializers.ListField()
upstream_info = UpstreamInfoSerializer(allow_null=True)
actions = serializers.SerializerMethodField()
validation_messages = MessageValidation(many=True)
render_error = serializers.CharField()
Expand Down
18 changes: 18 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ def get(self, request: Request, usage_key_string: str):
"block_type": "drag-and-drop-v2",
"user_partition_info": {},
"user_partitions": {}
"upstream_info": null,
"actions": {
"can_copy": true,
"can_duplicate": true,
Expand All @@ -215,6 +216,14 @@ def get(self, request: Request, usage_key_string: str):
"block_type": "video",
"user_partition_info": {},
"user_partitions": {}
"upstream_info": {
"usage_key": "lb:org:mylib:video:404",
"current_version": 16
"latest_version": null,
"sync_url": "http://...",
"error": "Linked library item not found: lb:org:mylib:video:404",
"can_sync": false,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
Expand All @@ -232,6 +241,14 @@ def get(self, request: Request, usage_key_string: str):
"block_type": "html",
"user_partition_info": {},
"user_partitions": {},
"upstream_info": {
"usage_key": "lb:org:mylib:html:abcd",
"current_version": 43,
"latest_version": 49,
"sync_url": "http://...",
"error": "null",
"can_sync": true,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
Expand Down Expand Up @@ -277,6 +294,7 @@ def get(self, request: Request, usage_key_string: str):
"block_type": child_info.location.block_type,
"user_partition_info": user_partition_info,
"user_partitions": user_partitions,
"upstream_info": child_info.get_upstream_info(),
"validation_messages": validation_messages,
"render_error": render_error,
})
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,
'cms.lib.xblock.upstream_sync.UpstreamSyncMixin',
)

# .. setting_name: XBLOCK_EXTRA_MIXINS
Expand Down
228 changes: 228 additions & 0 deletions cms/lib/xblock/upstream_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"""
Synchronize content and settings from upstream blocks to their downstream usages.
At the time of writing, upstream blocks are assumed to come from content libraries,
and downstream blocks will generally belong to courses. However, the system is designed
to be mostly agnostic to exact type of upstream context and type of downstream context.
"""
import json
from dataclasses import dataclass

from django.contrib.auth import get_user_model
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import LibraryUsageLocatorV2
from rest_framework.exceptions import NotFound
from xblock.exceptions import XBlockNotFoundError
from xblock.fields import Scope, String, Integer, Dict, List
from xblock.core import XBlockMixin, XBlock
from webob import Request, Response

import openedx.core.djangoapps.xblock.api as xblock_api
from openedx.core.djangoapps.content_libraries.api import (
get_library_block,
LibraryXBlockMetadata,
)


User = get_user_model()


class UnsupportedUpstreamKeyType(Exception):
pass


def validate_upstream_key(usage_key: UsageKey | str) -> UsageKey:
"""
Raise an error if the provided key is not valid upstream reference.
Currently, only Learning-Core-backed Content Library blocks are valid upstreams, although this may
change in the future.
Raises: InvalidKeyError, UnsupporteUpstreamKeyType
"""
if isinstance(usage_key, str):
usage_key = UsageKey.from_string(usage_key)
if not isinstance(usage_key, LibraryUsageLocatorV2):
raise UnsupportedUpstreamKeyType(
"upstream key must be of type LibraryUsageLocatorV2; "
f"provided key '{usage_key}' is of type '{type(usage_key)}'"
)
return usage_key


@dataclass(frozen=True)
class UpstreamInfo:
"""
Metadata about a block's relationship with an upstream.
"""
usage_key: UsageKey
current_version: int
latest_version: int | None
sync_url: str
error: str | None

def sync_available(self) -> bool:
"""
Should the user be prompted to sync this block with upstream?
"""
return (
self.latest_version
and self.current_version < self.latest_version
and not self.error
)


class UpstreamSyncMixin(XBlockMixin):
"""
Mixed into CMS XBlocks so that they be associated & synced with uptsream (e.g. content library) blocks.
"""

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 this field "
"to hold a usage key for an upstream block that does not exist (or does not *yet* exist) on this instance, "
"particularly if this downstream block was imported from a different instance."
),
hidden=True,
default=None,
enforce_type=True,
)
upstream_version = Integer(
scope=Scope.settings,
help=(
"Record of the upstream block's version number at the time this block was created from it. If "
"upstream_version is smaller than the upstream block's latest version, then the user will be able to sync "
"updates into this downstream block."
),
hidden=True,
default=None,
enforce_type=True,
)
upstream_overridden = List(
scope=Scope.settings,
help=(
"Names of the fields which have values set on the upstream block yet have been explicitly overridden "
"on this downstream block. Unless explicitly cleared by ther user, these overrides will persist even "
"when updates are synced from the upstream."
),
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):
"""
Upon save, ensure that upstream_overriden tracks all upstream-provided fields which downstream has overridden.
@@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()

@XBlock.handler
def sync_updates(self, request: Request, suffix=None) -> Response:
"""
XBlock handler
"""
if request.method != "POST":
return Response(status_code=405)
if not self.upstream:
return Response("no linked upstream", response=400)
upstream_info = self.get_upstream_info()
if upstream_info["error"]:
return Response(upstream_info["error"], status_code=400)
self.sync_from_upstream(user=request.user, apply_updates=True)
self.save()
self.runtime.modulestore.update_item(self, request.user.id)
return Response(json.dumps(self.get_upstream_info()), indent=4)

def sync_from_upstream(self, *, user: User, apply_updates: bool) -> None:
"""
@@TODO docstring
Does NOT save the block; that is left to the caller.
Raises: InvalidKeyError, UnsupportedUpstreamKeyType, XBlockNotFoundError
"""
if not self.upstream:
self.upstream_settings = {}
self.upstream_overridden = []
self.upstream_version = None
return
upstream_key = validate_upstream_key(self.upstream)
self.upstream_settings = {}
try:
upstream_block = xblock_api.load_block(upstream_key, user)
except NotFound as exc:
raise XBlockNotFoundError(
f"failed to load upstream ({self.upstream}) for block ({self.usage_key}) for user id={user.id}"
) from exc
for field_name, field in upstream_block.fields.items():
if field.scope not in [Scope.settings, Scope.content]:
continue
value = getattr(upstream_block, 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)
self.upstream_version = self._lib_block.version_num

def get_upstream_info(self) -> UpstreamInfo | None:
"""
@@TODO
"""
if not self.upstream:
return None
latest: int | None = None
error: str | None = None
try:
latest = self._lib_block.version_num
except InvalidKeyError:
error = _("Reference to linked library item is malformed: {}").format(self.upstream)
latest = None
except XBlockNotFoundError:
error = _("Linked library item was not found in the system: {}").format(self.upstream)
latest = None
return UpstreamInfo(
usage_key=self.upstream,
current_version=self.upstream_version,
latest_version=latest,
sync_url=self.runtime.handler_url(self, 'sync_updates'),
error=error,
)

@cached_property
def _lib_block(self) -> LibraryXBlockMetadata | None:
"""
Internal cache of the upstream library XBlock metadata, or None if there is no upstream assigned.
We assume, for now, that upstreams are always Learning-Core-backed Content Library blocks.
That is an INTERNAL ASSUMPTION that may change at some point; hence, this is a private
property; callers should use the public API methods which don't assume that the upstream is
a from a content library.
Raises: InvalidKeyError, XBlockNotFoundError
"""
if not self.upstream:
return None
upstream_key = validate_upstream_key(self.upstream)
return get_library_block(upstream_key)

0 comments on commit 28873db

Please sign in to comment.