Files
edx-platform/cms/lib/xblock/upstream_sync_block.py
Navin Karkera a11086ffac feat: allow editing imported text blocks (#37124)
* feat: allow editing html block imported from upstream

The modified field is left untouched in future sync while storing the
upstream values in hidden fields to allow authors to revert to upstream
version at any point.

* fix: sync downstream_customized field for copy-pasted modified block

* test: add more tests

* fix: lint issues

* test: copy paste

* feat: skip sync if html data is modified

* feat: update upstream fields only when modified

* refactor: use version_synced field to skip sync

* feat: edit title inplace for library source components

* fixup! feat: edit title inplace for library source components

* fix: edit title button style

* fix: test case

* fix: lint issue

* refactor: don't show different icon for modified upstream blocks

* Revert "refactor: use version_synced field to skip sync"

This reverts commit 8b784fff2f49b43702c952e7f955bd4048e8cc69.

* feat: only skip sync for modified blocks if updated as part of container

* refactor: update sync behaviour when synced individually and as part of parent

* feat: include ready to sync children info in downstream link get api

* test: fix failing tests

* fix: lint issues

* feat: new tests and update api to allow overriding modified fields in sync

* test: api changes

* refactor: edit options should be visible for individual imports

* docs: update api docs

* chore: remove old comments
2025-09-17 14:50:24 +05:30

213 lines
8.7 KiB
Python

"""
Methods related to syncing a downstream XBlock with an upstream XBlock.
See upstream_sync.py for general upstream sync code that applies even when the
upstream is a container, not an XBlock.
"""
from __future__ import annotations
import logging
import typing as t
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from opaque_keys.edx.locator import LibraryUsageLocatorV2
from rest_framework.exceptions import NotFound
from xblock.core import XBlock
from xblock.fields import Scope
from .upstream_sync import BadDownstream, BadUpstream, UpstreamLink
if t.TYPE_CHECKING:
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
logger = logging.getLogger(__name__)
def sync_from_upstream_block(
downstream: XBlock,
user: User,
*,
top_level_parent: XBlock | None = None,
override_customizations: bool = False,
keep_custom_fields: list[str] | None = None,
) -> XBlock | None:
"""
Update `downstream` with content+settings from the latest available version of its linked upstream content.
Preserves overrides to customizable fields; overwrites overrides to other fields.
Does not save `downstream` to the store. That is left up to the caller.
⭐️ Does not save changes to modulestore nor handle static assets. The caller
will have to take care of that.
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
"""
link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException
if not isinstance(link.upstream_key, LibraryUsageLocatorV2):
raise TypeError("sync_from_upstream_block() only supports XBlock upstreams, not containers")
upstream = _load_upstream_block(downstream, user)
# Upstream is a library block:
# Sync all fields from the upstream block and override customizations
_update_customizable_fields(
upstream=upstream,
downstream=downstream,
only_fetch=False,
override_customizations=override_customizations,
keep_custom_fields=keep_custom_fields,
)
_update_non_customizable_fields(upstream=upstream, downstream=downstream)
_update_tags(upstream=upstream, downstream=downstream)
downstream.upstream_version = link.version_available
return upstream
def fetch_customizable_fields_from_block(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None:
"""
Fetch upstream-defined value of customizable fields and save them on the downstream.
If `upstream` is provided, use that block as the upstream.
Otherwise, load the block specified by `downstream.upstream`, which may raise an UpstreamLinkException.
"""
if not upstream:
upstream = _load_upstream_block(downstream, user)
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True)
def _verify_modification_to(downstream: XBlock, allowed_fields: list[str]):
"""
Raise error if any field except for fields in allowed_fields is modified in course locally.
"""
if len(downstream.downstream_customized) > len(allowed_fields):
raise BadDownstream("Too many fields modified, skip sync operation")
not_allowed_modified = set(downstream.downstream_customized).difference(allowed_fields)
if len(not_allowed_modified) > 0:
raise BadDownstream(f"{not_allowed_modified} fields are modified locally")
def _load_upstream_block(downstream: XBlock, user: User) -> XBlock:
"""
Load the upstream metadata and content for a downstream block.
Assumes that the upstream content is an XBlock in an LC-backed content libraries. This assumption may need to be
relaxed in the future (see module docstring).
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
"""
# We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
try:
lib_block: XBlock = load_block(
LibraryUsageLocatorV2.from_string(downstream.upstream),
user,
check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
version=LatestVersion.PUBLISHED,
)
except (NotFound, PermissionDenied) as exc:
raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
return lib_block
def _update_customizable_fields(
*,
upstream: XBlock,
downstream: XBlock,
only_fetch: bool,
override_customizations: bool = False,
keep_custom_fields: list[str] | None = None,
) -> None:
"""
For each customizable field:
* Save the upstream value to a hidden field on the downstream ("FETCH").
* If `not only_fetch`, and if the field *isn't* customized on the downstream
or if override_customizations=True and keep_custom_fields does not contain the field name, then:
* Update it the downstream field's value from the upstream field ("SYNC").
* Remove the field from downstream.downstream_customized field if exists.
Concrete example: Imagine `lib_problem` is our upstream and `course_problem` is our downstream.
* Say that the customizable fields are [display_name, max_attempts].
* Set `course_problem.upstream_display_name = lib_problem.display_name` ("fetch").
* If `not only_fetch`, and `course_problem.display_name` wasn't customized, then:
* Set `course_problem.display_name = lib_problem.display_name` ("sync").
"""
syncable_field_names = _get_synchronizable_fields(upstream, downstream)
for field_name, fetch_field_name in downstream.get_customizable_fields().items():
if field_name not in syncable_field_names:
continue
# Downstream-only fields don't have an upstream fetch field
if fetch_field_name is None:
continue
# FETCH the upstream's value and save it on the downstream (ie, `downstream.upstream_$FIELD`).
new_upstream_value = getattr(upstream, field_name)
setattr(downstream, fetch_field_name, new_upstream_value)
if only_fetch:
continue
# Okay, now for the nuanced part...
# We need to update the downstream field *iff it has not been customized**.
if field_name in downstream.downstream_customized:
if not override_customizations or keep_custom_fields and field_name in keep_custom_fields:
continue
else:
# Remove the field from downstream_customized field as it can be overridden
downstream.downstream_customized.remove(field_name)
# Field isn't customized or is can be overridden -- SYNC it!
setattr(downstream, field_name, new_upstream_value)
def _update_non_customizable_fields(*, upstream: XBlock, downstream: XBlock) -> None:
"""
For each field `downstream.blah` that isn't customizable: set it to `upstream.blah`.
"""
syncable_fields = _get_synchronizable_fields(upstream, downstream)
# Remove both field_name and its upstream_* counterpart from the list of fields to copy
customizable_fields = set(downstream.get_customizable_fields().keys()) | set(
downstream.get_customizable_fields().values()
)
# TODO: resolve this so there's no special-case happening for video block.
# e.g. by some non_cloneable_fields property of the XBlock class?
is_video_block = downstream.usage_key.block_type == "video"
for field_name in syncable_fields - customizable_fields:
if is_video_block and field_name == 'edx_video_id':
# Avoid overwriting edx_video_id between blocks
continue
new_upstream_value = getattr(upstream, field_name)
setattr(downstream, field_name, new_upstream_value)
def _update_tags(*, upstream: XBlock, downstream: XBlock) -> None:
"""
Update tags from `upstream` to `downstream`
"""
from openedx.core.djangoapps.content_tagging.api import copy_tags_as_read_only
# For any block synced with an upstream, copy the tags as read_only
# This keeps tags added locally.
copy_tags_as_read_only(
str(upstream.location),
str(downstream.location),
)
def _get_synchronizable_fields(upstream: XBlock, downstream: XBlock) -> set[str]:
"""
The syncable fields are the ones which are content- or settings-scoped AND are defined on both (up,down)stream.
"""
return set.intersection(*[
set(
field_name
for (field_name, field) in block.__class__.fields.items()
if field.scope in [Scope.settings, Scope.content]
)
for block in [upstream, downstream]
])