* feat: library unit sync * feat: create component link only for component xblocks * feat: container link model * feat: update downstream api views * feat: delete extra components in container on sync (not working) * fix: duplicate definitions of LibraryXBlockMetadata * test: add a new integration test suite for syncing * feat: partially implement container+child syncing * fix: blockserializer wasn't always serializing all HTML block fields * feat: handle reorder, addition and deletion of components in sync Updates children components of unit in course based on upstream unit, deletes removed component, adds new ones and updates order as per upstream. * feat: return unit upstreamInfo and disallow edits to units in courses that are sourced from a library (#773) * feat: Add upstream_info to unit * feat: disallow edits to units in courses that are sourced from a library (#774) --------- Co-authored-by: Jillian Vogel <jill@opencraft.com> Co-authored-by: Rômulo Penido <romulo.penido@gmail.com> * docs: capitalization of XBlock Co-authored-by: David Ormsbee <dave@axim.org> * refactor: (minor) change python property name to reflect type better * fix: lots of "Tried to inspect a missing...upstream link" warnings when viewing a unit in Studio * docs: mention potential REST API for future refactor * fix: check if upstream actually exists before making unit read-only * chore: fix camel-case var * fix: test failure when mocked XBlock doesn't have UpstreamSyncMixin --------- Co-authored-by: Braden MacDonald <braden@opencraft.com> Co-authored-by: Chris Chávez <xnpiochv@gmail.com> Co-authored-by: Jillian Vogel <jill@opencraft.com> Co-authored-by: Rômulo Penido <romulo.penido@gmail.com> Co-authored-by: Braden MacDonald <mail@bradenm.com> Co-authored-by: David Ormsbee <dave@axim.org>
177 lines
7.6 KiB
Python
177 lines
7.6 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 typing as t
|
|
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.utils.translation import gettext_lazy as _
|
|
from rest_framework.exceptions import NotFound
|
|
from opaque_keys.edx.locator import LibraryUsageLocatorV2
|
|
from xblock.fields import Scope
|
|
from xblock.core import XBlock
|
|
|
|
from .upstream_sync import UpstreamLink, BadUpstream
|
|
|
|
if t.TYPE_CHECKING:
|
|
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
|
|
|
|
|
|
def sync_from_upstream_block(downstream: XBlock, user: User) -> XBlock:
|
|
"""
|
|
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 is a library block:
|
|
upstream = _load_upstream_block(downstream, user)
|
|
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=False)
|
|
_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 _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) -> 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, then:
|
|
* Update it the downstream field's value from the upstream field ("SYNC").
|
|
|
|
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`).
|
|
old_upstream_value = getattr(downstream, fetch_field_name)
|
|
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**.
|
|
# Determining whether a field has been customized will differ in Beta vs Future release.
|
|
# (See "PRESERVING DOWNSTREAM CUSTOMIZATIONS" comment below for details.)
|
|
|
|
## FUTURE BEHAVIOR: field is "customized" iff we have noticed that the user edited it.
|
|
# if field_name in downstream.downstream_customized:
|
|
# continue
|
|
|
|
## BETA BEHAVIOR: field is "customized" iff we have the prev upstream value, but field doesn't match it.
|
|
downstream_value = getattr(downstream, field_name)
|
|
if old_upstream_value and downstream_value != old_upstream_value:
|
|
continue # Field has been customized. Don't touch it. Move on.
|
|
|
|
# Field isn't customized -- 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)
|
|
customizable_fields = set(downstream.get_customizable_fields().keys())
|
|
# 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]
|
|
])
|