diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index d72707ed7836..dc74364be69b 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -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. @@ -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() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index 670b94afbbe0..8381ffff5f27 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -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 @@ -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 @@ -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, @@ -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, @@ -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, @@ -270,6 +287,30 @@ 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, @@ -277,6 +318,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": upstream_info, "validation_messages": validation_messages, "render_error": render_error, }) diff --git a/cms/envs/common.py b/cms/envs/common.py index be837c518981..ed28a016ca20 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1020,6 +1020,7 @@ XModuleMixin, EditInfoMixin, AuthoringMixin, + "openedx.core.djangoapps.content_libraries.sync.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..d29bb1086ec8 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/sync.py @@ -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)