Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e625f68
feat: allow editing html block imported from upstream
navinkarkera Aug 1, 2025
20edd32
fix: sync downstream_customized field for copy-pasted modified block
navinkarkera Aug 5, 2025
554210a
test: add more tests
navinkarkera Aug 5, 2025
f61c800
fix: lint issues
navinkarkera Aug 6, 2025
301ce21
test: copy paste
navinkarkera Aug 6, 2025
d262f5e
feat: skip sync if html data is modified
navinkarkera Aug 7, 2025
b64be2c
Merge branch 'master' into navin/fal-4229/allow-text-editing
navinkarkera Aug 15, 2025
d2ba2e7
feat: update upstream fields only when modified
navinkarkera Aug 15, 2025
8b784ff
refactor: use version_synced field to skip sync
navinkarkera Aug 18, 2025
6876f7f
Merge branch 'master' into navin/fal-4229/allow-text-editing
navinkarkera Aug 22, 2025
026e248
feat: edit title inplace for library source components
navinkarkera Aug 21, 2025
04aaef1
fixup! feat: edit title inplace for library source components
navinkarkera Aug 21, 2025
ae2ed50
fix: edit title button style
navinkarkera Aug 22, 2025
083df77
fix: test case
navinkarkera Aug 22, 2025
9afb91d
fix: lint issue
navinkarkera Aug 22, 2025
c4a6961
refactor: don't show different icon for modified upstream blocks
navinkarkera Aug 25, 2025
313a189
Merge branch 'master' into navin/fal-4229/allow-text-editing
navinkarkera Sep 10, 2025
adde721
Revert "refactor: use version_synced field to skip sync"
navinkarkera Sep 10, 2025
af030be
feat: only skip sync for modified blocks if updated as part of container
navinkarkera Sep 11, 2025
5f68c78
refactor: update sync behaviour when synced individually and as part …
navinkarkera Sep 12, 2025
8228a78
Merge branch 'master' into navin/fal-4229/allow-text-editing
navinkarkera Sep 12, 2025
f4e43e0
feat: include ready to sync children info in downstream link get api
navinkarkera Sep 12, 2025
8fdd55d
test: fix failing tests
navinkarkera Sep 12, 2025
285b348
Merge branch 'master' into navin/fal-4229/allow-text-editing
navinkarkera Sep 12, 2025
ae6e56d
fix: lint issues
navinkarkera Sep 12, 2025
83d0e7a
feat: new tests and update api to allow overriding modified fields in…
navinkarkera Sep 12, 2025
f20b0be
test: api changes
navinkarkera Sep 15, 2025
77cacbb
Merge branch 'master' into navin/fal-4229/allow-text-editing
navinkarkera Sep 15, 2025
81b0881
refactor: edit options should be visible for individual imports
navinkarkera Sep 15, 2025
c8dc6f8
docs: update api docs
navinkarkera Sep 15, 2025
7d8dc33
Merge branch 'master' into navin/fal-4229/allow-text-editing
navinkarkera Sep 17, 2025
55e6a02
chore: remove old comments
navinkarkera Sep 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cms/djangoapps/contentstore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Helper methods for Studio views.
"""
from __future__ import annotations
import json
import logging
import pathlib
import urllib
Expand Down Expand Up @@ -493,6 +494,14 @@ def _fetch_and_set_upstream_link(
# new values from the published upstream content.
if isinstance(upstream_link.upstream_key, UsageKey): # only if upstream is a block, not a container
fetch_customizable_fields_from_block(downstream=temp_xblock, user=user, upstream=temp_xblock)
# Although the above function will set all customisable fields to match its upstream_* counterpart
# We copy the downstream_customized list to the new block to avoid overriding user customisations on sync
# So we will have:
# temp_xblock.display_name == temp_xblock.upstream_display_name
# temp_xblock.data == temp_xblock.upstream_data # for html blocks
# Even then we want to set `downstream_customized` value to avoid overriding user customisations on sync
downstream_customized = temp_xblock.xml_attributes.get("downstream_customized", '[]')
temp_xblock.downstream_customized = json.loads(downstream_customized)


def _import_xml_node_to_parent(
Expand Down
8 changes: 7 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response
Inspect an XBlock's link to upstream content.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=False)
return Response(UpstreamLink.try_get_for_block(downstream).to_json())
return Response(UpstreamLink.try_get_for_block(downstream).to_json(include_child_info=True))

def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Expand Down Expand Up @@ -499,6 +499,12 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Pull latest updates to the block at {usage_key_string} from its linked upstream content.
Optionally accepts json data in below format to control override of locally customized fields
{
"override_customizations": True or False (default: False),
"keep_custom_fields": [] (If override_customizations is True, this key can be used to preserve
some fields from override).
}
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
try:
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def _get_upstream_link_good_and_syncable(downstream):
version_available=(downstream.upstream_version or 0) + 1,
version_declined=downstream.upstream_version_declined,
error_message=None,
is_modified=False,
has_top_level_parent=False,
)

Expand Down Expand Up @@ -625,6 +626,29 @@ def call_api(
data["use_top_level_parents"] = str(use_top_level_parents)
return self.client.get("/api/contentstore/v2/downstreams/", data=data)

def test_200_single_upstream_container(self):
"""
Test single upstream container link provides children info as well.
"""
self.client.login(username="superuser", password="password")
# Publish components
self._set_library_block_olx(self.html_lib_id_2, "<html><b>Hello world!</b></html>")
self._publish_library_block(self.html_lib_id_2)

response = self.client.get(f"/api/contentstore/v2/downstreams/{self.top_level_downstream_unit.usage_key}")
assert response.status_code == 200
data = response.json()
assert data['upstream_ref'] == self.top_level_unit_id
assert data['error_message'] is None
assert data['ready_to_sync'] is True
assert len(data['ready_to_sync_children']) == 1
html_block = modulestore().get_item(self.top_level_downstream_html_key)
self.assertDictEqual(data['ready_to_sync_children'][0], {
'name': html_block.display_name,
'upstream': str(self.html_lib_id_2),
'id': str(html_block.usage_key),
})

def test_200_all_downstreams_for_a_course(self):
"""
Returns all links for given course
Expand Down
18 changes: 15 additions & 3 deletions cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
"""
Wraps the results of rendering an XBlock view in a div which adds a header and Studio action buttons.
"""
# Allow some imported components to be edited by authors in course.
editable_library_components = ["html"]
# Only add the Studio wrapper when on the container page. The "Pages" page will remain as is for now.
if not context.get('is_pages_view', None) and view in PREVIEW_VIEWS:
root_xblock = context.get('root_xblock')
Expand All @@ -304,13 +306,22 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):

can_edit = context.get('can_edit', True)
can_add = context.get('can_add', True)
upstream_link = UpstreamLink.try_get_for_block(root_xblock, log_error=False)
if upstream_link.error_message is None and isinstance(upstream_link.upstream_key, LibraryContainerLocator):
can_move = context.get('can_move', True)
root_upstream_link = UpstreamLink.try_get_for_block(root_xblock, log_error=False)
upstream_link = UpstreamLink.try_get_for_block(xblock, log_error=False)
if (
root_upstream_link.error_message is None
and isinstance(root_upstream_link.upstream_key, LibraryContainerLocator)
):
# If this unit is linked to a library unit, for now we make it completely read-only
# because when it is synced, all local changes like added components will be lost.
# (This is only on the frontend; the backend doesn't enforce it)
can_edit = False
can_add = False
can_move = False

if upstream_link.error_message is None and upstream_link.upstream_ref:
can_edit = xblock.category in editable_library_components

# Is this a course or a library?
is_course = xblock.context_key.is_course
Expand All @@ -336,10 +347,11 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
# Generally speaking, "if you can add, you can delete". One exception is itembank (Problem Bank)
# which has its own separate "add" workflow but uses the normal delete workflow for its child blocks.
'can_delete': can_add or (root_xblock and root_xblock.scope_ids.block_type == "itembank" and can_edit),
'can_move': context.get('can_move', is_course),
'can_move': can_move,
'language': getattr(course, 'language', None),
'is_course': is_course,
'tags_count': tags_count,
'can_edit_title': True, # This is always true even for imported components
}

add_webpack_js_to_fragment(frag, "js/factories/xblock_validation")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,11 @@ def modify_xblock(usage_key, request):
)


def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
def save_xblock_with_callback(xblock, user, old_metadata=None, old_content=None):
"""
Updates the xblock in the modulestore.
But before doing so, it calls the xblock's editor_saved callback function,
and after doing so, it calls the xblock's post_editor_saved callback function.

TODO: Remove getattrs from this function.
See https://github.com/openedx/edx-platform/issues/33715
"""
if old_metadata is None:
old_metadata = own_metadata(xblock)
Expand Down Expand Up @@ -377,7 +374,7 @@ def _save_xblock(
if old_parent_location:
old_parent = store.get_item(old_parent_location)
old_parent.children.remove(new_child)
old_parent = _update_with_callback(old_parent, user)
old_parent = save_xblock_with_callback(old_parent, user)
else:
# the Studio UI currently doesn't present orphaned children, so assume this is an error
return JsonResponse(
Expand Down Expand Up @@ -447,7 +444,7 @@ def _save_xblock(

validate_and_update_xblock_due_date(xblock)
# update the xblock and call any xblock callbacks
xblock = _update_with_callback(xblock, user, old_metadata, old_content)
xblock = save_xblock_with_callback(xblock, user, old_metadata, old_content)

# for static tabs, their containing course also records their display name
course = store.get_course(xblock.location.course_key)
Expand Down Expand Up @@ -533,17 +530,29 @@ def sync_library_content(
downstream: XBlock,
request,
store,
top_level_parent: XBlock | None = None
top_level_parent: XBlock | None = None,
) -> StaticFileNotices:
"""
Handle syncing library content for given xblock depending on its upstream type.
It can sync unit containers and lower level xblocks.
"""
link = UpstreamLink.get_for_block(downstream)
upstream_key = link.upstream_key
request_data = getattr(request, "json", getattr(request, "data", {}))
override_customizations = request_data.get("override_customizations", False)
keep_custom_fields = request_data.get("keep_custom_fields", [])
if isinstance(upstream_key, LibraryUsageLocatorV2):
lib_block = sync_from_upstream_block(downstream=downstream, user=request.user)
static_file_notices = import_static_assets_for_library_sync(downstream, lib_block, request)
lib_block = sync_from_upstream_block(
downstream=downstream,
user=request.user,
top_level_parent=top_level_parent,
override_customizations=override_customizations,
keep_custom_fields=keep_custom_fields,
)
if lib_block:
static_file_notices = import_static_assets_for_library_sync(downstream, lib_block, request)
else:
static_file_notices = StaticFileNotices()
store.update_item(downstream, request.user.id)
else:
with store.bulk_operations(downstream.usage_key.context_key):
Expand Down Expand Up @@ -1678,7 +1687,7 @@ def _get_release_date(xblock, user=None):

if reset_to_default and user:
xblock.start = DEFAULT_START_DATE
xblock = _update_with_callback(xblock, user)
xblock = save_xblock_with_callback(xblock, user)

# Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
return (
Expand Down
2 changes: 1 addition & 1 deletion cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,8 +844,8 @@
ResourceTemplates,
XModuleMixin,
EditInfoMixin,
UpstreamSyncMixin, # Should be above AuthoringMixin for UpstreamSyncMixin.editor_saved to take effect
AuthoringMixin,
UpstreamSyncMixin,
)

# .. setting_name: XBLOCK_EXTRA_MIXINS
Expand Down
Loading
Loading