Skip to content

Commit

Permalink
feat: Upstream Sync with Content Library Blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
kdmccormick committed Sep 25, 2024
1 parent 0e061de commit fc46b57
Show file tree
Hide file tree
Showing 10 changed files with 651 additions and 11 deletions.
37 changes: 29 additions & 8 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,6 +23,7 @@
from xmodule.xml_block import XmlMixin

from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.upstream_sync import BadUpstream, sync_from_upstream
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
import openedx.core.djangoapps.content_staging.api as content_staging_api
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
Expand All @@ -30,6 +32,10 @@

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 @@ -250,7 +256,9 @@ class StaticFileNotices:
error_files: list[str] = Factory(list)


def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tuple[XBlock | None, StaticFileNotices]:
def import_staged_content_from_user_clipboard(
parent_key: UsageKey, request, *, link_to_upstream: bool = False
) -> tuple[XBlock | None, StaticFileNotices]:
"""
Import a block (along with its children and any required static assets) from
the "staged" OLX in the user's clipboard.
Expand Down Expand Up @@ -282,18 +290,18 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
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,
link_to_upstream=link_to_upstream,
)
# Now handle static files that need to go into Files & Uploads:
notices = _import_files_into_course(
course_key=parent_key.context_key,
staged_content_id=user_clipboard.content.id,
static_files=static_files,
)

return new_xblock, notices


Expand All @@ -302,14 +310,16 @@ 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
copied_from_block: str | None = None,
# Content tags applied to the source XBlock(s)
tags: dict[str, str] | None = None,
*,
link_to_upstream: bool = False,
) -> XBlock:
"""
Given an XML node representing a serialized XBlock (OLX), import it into modulestore 'store' as a child of the
Expand Down Expand Up @@ -375,10 +385,21 @@ 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
if copied_from_block and link_to_upstream:
# If requested, link this block as a downstream of where it was copied from
temp_xblock.upstream = copied_from_block
try:
sync_from_upstream(temp_xblock, user, apply_updates=False)
except BadUpstream as exc:
log.exception(
"Pasting content with link_to_upstream=True, but copied content is not a valid upstream. Will not link."
)
temp_xblock.upstream = None

# 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 @@ -394,7 +415,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,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).
"""
upstream_ref = serializers.CharField()
current_version = serializers.IntegerField(allow_null=True)
latest_version = serializers.IntegerField(allow_null=True)
warning = serializers.CharField(allow_null=True)
can_sync = serializers.BooleanField()


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 @@ -2,6 +2,7 @@

import logging
import edx_api_doc_tools as apidocs
from dataclasses import asdict
from django.http import HttpResponseBadRequest
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -20,6 +21,7 @@
ContainerHandlerSerializer,
VerticalContainerSerializer,
)
from cms.lib.xblock.upstream_sync import BadUpstream, BadDownstream, inspect_upstream_link
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": {
"upstream_ref": "lb:org:mylib:video:404",
"current_version": 16
"latest_version": null,
"warning": "Linked library item not found: lb:org:mylib:video:404",
"can_sync": false,
},
"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": {
"upstream_ref": "lb:org:mylib:html:abcd",
"current_version": 43,
"latest_version": 49,
"warning": null,
"can_sync": true,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
Expand Down Expand Up @@ -265,6 +282,30 @@ def get(self, request: Request, usage_key_string: str):
if hasattr(current_xblock, "children"):
for child in current_xblock.children:
child_info = modulestore().get_item(child)
try:
upstream_info = inspect_upstream_link(child_info)
except BadDownstream as exc:
upstream_info_json = None
except BadUpstream as exc:
upstream_info_json = {
"upstream_ref": child_info.upstream,
"current_version": None,
"latest_version": None,
"can_sync": False,
"warning": str(exc),
}
else:
if upstream_info:
upstream_info_json = {
**asdict(upstream_info),
"can_sync": (
upstream_info.upstream and
upstream_info.latest_version > upstream_info.current_version
),
"warning": None,
}
else:
upstream_info_json = None
user_partition_info = get_visibility_partition_info(child_info, course=course)
user_partitions = get_user_partition_info(child_info, course=course)
validation_messages = get_xblock_validation_messages(child_info)
Expand All @@ -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_json,
"validation_messages": validation_messages,
"render_error": render_error,
})
Expand Down
13 changes: 11 additions & 2 deletions cms/djangoapps/contentstore/rest_api/v2/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Contenstore API v2 URLs."""

from django.urls import path
from django.conf import settings
from django.urls import path, re_path

from cms.djangoapps.contentstore.rest_api.v2.views import HomePageCoursesViewV2
from cms.djangoapps.contentstore.rest_api.v2.views import (
HomePageCoursesViewV2,
UpstreamSyncView,
)

app_name = "v2"

Expand All @@ -12,4 +16,9 @@
HomePageCoursesViewV2.as_view(),
name="courses",
),
re_path(
fr'^upstream_sync/{settings.USAGE_KEY_PATTERN}$',
UpstreamSyncView.as_view(),
name="upstream_sync"
),
]
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/rest_api/v2/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"""Module for v2 views."""

from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2
from cms.djangoapps.contentstore.rest_api.v2.views.upstream_sync import UpstreamSyncView
72 changes: 72 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/views/upstream_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
""" API Views for syncing upstream content to downstream content """

import edx_api_doc_tools as apidocs
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from cms.lib.xblock.upstream_sync import sync_from_upstream, BadUpstream, BadDownstream
from common.djangoapps.student.auth import has_studio_write_access
from openedx.core.lib.api.view_utils import (
DeveloperErrorViewMixin,
view_auth_classes,
)
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError


@view_auth_classes(is_authenticated=True)
class UpstreamSyncView(DeveloperErrorViewMixin, APIView):
"""
@@TODO
"""

@apidocs.schema(
parameters=[
apidocs.string_parameter(
"course_id", apidocs.ParameterLocation.PATH, description="Course ID"
),
],
responses={
200: None,
400: "Downstream block ID is invalid.",
401: "The requester is not authenticated.",
403: "The requester cannot modify the specified downstream block.",
404: "The specified downstream block does not exist.",
422: "Failed to sync content.",
},
)
def post(self, request: Request, usage_id: str):
"""
Pull latest updates to the block at {usage_id} from its linked upstream content.
**Example Request**
POST /api/contentstore/v1/upstream_sync/{usage_id}
**Response Values**
If the request is successful, an HTTP 200 "OK" response is returned, with no body.
If the request fails, an HTTP 4xx response is returned, with an error message.
"""
try:
usage_key = UsageKey.from_string(usage_id)
except InvalidKeyError:
return Response("Invalid block key", status=400)
if not has_studio_write_access(request.user, usage_key.context_key):
self.permission_denied(request)

store = modulestore()
with store.bulk_operations(usage_key.context_key):
try:
downstream = store.get_item(usage_key)
except ItemNotFoundError:
return Response("Block not found", status=404)
try:
sync_from_upstream(downstream, request.user, apply_updates=True)
except (BadUpstream, BadDownstream) as exc:
return Response(str(exc), status=422)
downstream.save()
store.update_item(downstream, request.user.id)
return Response()
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,9 @@ def _create_block(request):
# Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key':
try:
created_xblock, notices = import_staged_content_from_user_clipboard(
parent_key=usage_key, request=request
parent_key=usage_key,
request=request,
link_to_upstream=request.json.get("link_to_upstream"),
)
except Exception: # pylint: disable=broad-except
log.exception(
Expand Down
1 change: 1 addition & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,7 @@
XModuleMixin,
EditInfoMixin,
AuthoringMixin,
'cms.lib.xblock.upstream_sync.UpstreamSyncMixin',
)

# .. setting_name: XBLOCK_EXTRA_MIXINS
Expand Down
Loading

0 comments on commit fc46b57

Please sign in to comment.