* 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>
140 lines
6.1 KiB
Python
140 lines
6.1 KiB
Python
"""
|
|
Methods related to syncing a downstream XBlock with an upstream Container.
|
|
|
|
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.utils.translation import gettext_lazy as _
|
|
from opaque_keys.edx.locator import LibraryContainerLocator
|
|
from xblock.core import XBlock
|
|
|
|
from openedx.core.djangoapps.content_libraries import api as lib_api
|
|
from .upstream_sync import UpstreamLink
|
|
|
|
if t.TYPE_CHECKING:
|
|
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
|
|
|
|
|
|
def sync_from_upstream_container(
|
|
downstream: XBlock,
|
|
user: User,
|
|
) -> list[lib_api.LibraryXBlockMetadata | lib_api.ContainerMetadata]:
|
|
"""
|
|
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.
|
|
|
|
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
|
|
|
|
⭐️ Does not directly sync static assets (containers don't have them) nor
|
|
children. Returns a list of the upstream children so the caller can do that.
|
|
|
|
Should children be handled in here? Maybe if sync_from_upstream_block
|
|
were updated to handle static assets and also save changes to modulestore.
|
|
"""
|
|
link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException
|
|
if not isinstance(link.upstream_key, LibraryContainerLocator):
|
|
raise TypeError("sync_from_upstream_container() only supports Container upstreams, not containers")
|
|
lib_api.require_permission_for_library_key( # TODO: should permissions be checked at this low level?
|
|
link.upstream_key.lib_key,
|
|
user,
|
|
permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY,
|
|
)
|
|
upstream_meta = lib_api.get_container(link.upstream_key, user)
|
|
upstream_children = lib_api.get_container_children(link.upstream_key, published=True)
|
|
_update_customizable_fields(upstream=upstream_meta, downstream=downstream, only_fetch=False)
|
|
_update_non_customizable_fields(upstream=upstream_meta, downstream=downstream)
|
|
_update_tags(upstream=upstream_meta, downstream=downstream)
|
|
downstream.upstream_version = link.version_available
|
|
return upstream_children
|
|
|
|
|
|
def fetch_customizable_fields_from_container(*, downstream: XBlock, user: User) -> None:
|
|
"""
|
|
Fetch upstream-defined value of customizable fields and save them on the downstream.
|
|
|
|
The container version only retrieves values from *published* containers.
|
|
|
|
Basically, this sets the value of "upstream_display_name" on the downstream block.
|
|
"""
|
|
upstream = lib_api.get_container(LibraryContainerLocator.from_string(downstream.upstream), user)
|
|
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True)
|
|
|
|
|
|
def _update_customizable_fields(*, upstream: lib_api.ContainerMetadata, 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").
|
|
"""
|
|
# For now, the only supported container "field" is display_name
|
|
syncable_field_names = ["display_name"]
|
|
|
|
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, f"published_{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: lib_api.ContainerMetadata, downstream: XBlock) -> None:
|
|
"""
|
|
For each field `downstream.blah` that isn't customizable: set it to `upstream.blah`.
|
|
"""
|
|
# For now, there's nothing to do here - containers don't have any non-customizable fields.
|
|
|
|
|
|
def _update_tags(*, upstream: lib_api.ContainerMetadata, 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.container_key),
|
|
str(downstream.usage_key),
|
|
)
|