Skip to content

Commit

Permalink
feat: UpstreamSyncMixin
Browse files Browse the repository at this point in the history
  • Loading branch information
kdmccormick committed Jul 31, 2024
1 parent aaf6ac7 commit 4ab414b
Show file tree
Hide file tree
Showing 4 changed files with 279 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ 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(allow_null=True)
error = serializers.CharField(allow_null=True)


class ChildVerticalContainerSerializer(serializers.Serializer):
"""
Serializer for representing a xblock child of vertical container.
Expand All @@ -113,6 +124,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
42 changes: 42 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 @@ -3,6 +3,7 @@
import logging
import edx_api_doc_tools as apidocs
from django.http import HttpResponseBadRequest
from opaque_keys import InvalidKeyError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
Expand All @@ -20,6 +21,7 @@
ContainerHandlerSerializer,
VerticalContainerSerializer,
)
from openedx.core.djangoapps.content_libraries.api import ContentLibraryBlockNotFound
from openedx.core.lib.api.view_utils import view_auth_classes
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -198,6 +200,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 +218,13 @@ 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",
},
"actions": {
"can_copy": true,
"can_duplicate": true,
Expand All @@ -232,6 +242,13 @@ 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",
},
"actions": {
"can_copy": true,
"can_duplicate": true,
Expand Down Expand Up @@ -270,13 +287,38 @@ def get(self, request: Request, usage_key_string: str):
validation_messages = get_xblock_validation_messages(child_info)
render_error = get_xblock_render_error(request, child_info)

if child_info.upstream:
upstream_current = child_info.upstream_version
upstream_latest = None
upstream_error = None
try:
upstream_latest = child_info.get_upstream_meta().version_num
except InvalidKeyError:
upstream_error = f"Linked library item key is malformed: {child_info.upstream}"
except ContentLibraryBlockNotFound:
upstream_error = f"Linked library item not found: {child_info.upstream}"
upstream_info = {
"usage_key": child_info.upstream,
"current_version": upstream_current,
"latest_version": upstream_latest,
"error": upstream_error,
"sync_url": (
child_info.runtime.handler_url(child_info, 'upgrade_and_sync')
if upstream_latest is not None and upstream_current < upstream_latest
else None
)
}
else:
upstream_info = None

children.append({
"xblock": child_info,
"name": child_info.display_name_with_default,
"block_id": child_info.location,
"block_type": child_info.location.block_type,
"user_partition_info": user_partition_info,
"user_partitions": user_partitions,
"upstream_info": 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,
"openedx.core.djangoapps.content_libraries.sync.UpstreamSyncMixin",
)

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

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


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) -> LibraryXBlockMetadata | None:
"""
Assign an upstream to this block and fetch upstream settings.
Raises: ContentLibraryBlockNotFound, NotFound
"""
old_upstream = self.upstream
self.upstream = str(upstream_key)
try:
return self._sync_with_upstream(apply=False)
except (ContentLibraryBlockNotFound, xblock_api.NotFound):
self.upstream = old_upstream
raise

@XBlock.handler
def upstream_link(self, request: Request, _suffix=None) -> Response:
"""
@@TODO docstring
GET: Retrieve upstream link
PUT: Set upstream link
DELETE: Remove upstream link
200: Success, with JSON data on upstream link
204: Success, no upstream link
400: Bad request data
401: Unauthenticated
405: Bad method
"""
if request.method == "DELETE":
self.assign_upstream(None)
return Response(status_code=204)
if request.method in ["PUT", "GET"]:
if request.method == "PUT":
try:
usage_key_string = json.loads(request.data["usage_key"])
except json.JSONDecodeError:
return Response("bad json", status_code=400)
except KeyError:
return Response("missing top-level key in json body: usage_key", status_code=400)
try:
usage_key = LibraryUsageLocatorV2.from_string(usage_key_string)
except InvalidKeyError:
return Response(f"not a valid library block usage key: {usage_key_string}", status_code=400)
try:
upstream_meta = self.assign_upstream(usage_key) # type: ignore[assignment]
except ContentLibraryBlockNotFound:
return Response(f"could not load library block metadata: {usage_key}", status_code=400)
if request.method == "GET":
try:
upstream_meta = self.get_upstream_meta()
except InvalidKeyError:
return Response(f"upstream is not a valid usage key: {self.upstream}", status_code=400)
except ContentLibraryBlockNotFound:
return Response(f"could not load upstream block metadata: {self.upstream}", status_code=400)
if not upstream_meta:
return Response(status_code=204)
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,
),
)
return Response(status_code=405)

@XBlock.handler
def upgrade_and_sync(self, request: Request, suffix=None) -> Response:
"""
@@TODO docstring
"""
if request.method != "POST":
return Response(status_code=405)
if not self.upstream:
return Response("no linked upstream", response=400)
try:
self._sync_with_upstream(apply=True)
except InvalidKeyError:
return Response(f"upstream is not a valid usage key: {self.upstream}", status_code=400)
except ContentLibraryBlockNotFound:
return Response(f"could not load upstream block metadata: {self.upstream}", status_code=400)
except xblock_api.NotFound:
return Response(f"could not load upstream block content: {self.upstream}", status_code=400)
self.save()
return Response(status_code=200)

def _sync_with_upstream(self, *, apply: bool) -> LibraryXBlockMetadata | None:
"""
@@TODO docstring
Raises: InvalidKeyError, ContentLibraryBlockNotFound, xblock_api.NotFoud
"""
upstream_meta = self.get_upstream_meta()
if not upstream_meta:
self.upstream_overridden = []
self.upstream_version = None
return None
self.upstream_settings = {}
# @@TODO: do we need user_id to get the block? if so, is there a better way to get it?
user_id = self.runtime.service(self, "user")._django_user.id # pylint: disable=protected-access
upstream_block = xblock_api.load_block(upstream_meta.usage_key, get_user_model().objects.get(id=user_id))
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:
continue
setattr(self, field_name, value)
self.upstream_version = upstream_meta.version_num
self.save()
# @@TODO why isn't self.save() sufficient? do we really need to invoke modulestore here?
from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order
modulestore().update_item(self, user_id)
return upstream_meta

def get_upstream_meta(self) -> LibraryXBlockMetadata | None:
"""
@@TODO docstring
Raises: InvalidKeyError, ContentLibraryBlockNotFound
"""
if not self.upstream:
return None
upstream_key = LibraryUsageLocatorV2.from_string(self.upstream)
return get_library_block(upstream_key)


def is_valid_upstream(usage_key: UsageKey) -> bool:
"""
@@TODO docstring
"""
return isinstance(usage_key, LibraryUsageLocatorV2)

0 comments on commit 4ab414b

Please sign in to comment.