build!: Switch to openedx-core (renamed from openedx-learning) Instead of installing openedx-learning==0.32.0, we install openedx-core==0.34.1. We update various class names, function names, docstrings, and comments to represent the rename: * We say "openedx-core" when referring to the whole repo or PyPI project * or occasionally "Open edX Core" if we want it to look nice in the docs. * We say "openedx_content" to refer to the Content API within openedx-core, which is actually the thing we have been calling "Learning Core" all along. * In snake-case code, it's `*_openedx_content_*`. * In camel-case code, it's `*OpenedXContent*` For consistency's sake we avoid anything else like oex_core, OeXCore, OpenEdXCore, OexContent, openedx-content, OpenEdxContent, etc. There should be no more references to learning_core, learning-core, Learning Core, Learning-Core, LC, openedx-learning, openedx_learning, etc. BREAKING CHANGE: for openedx-learning/openedx-core developers: You may need to uninstall openedx-learning and re-install openedx-core from your venv. If running tutor, you may need to un-mount openedx-learning, rename the directory to openedx-core, re-mount it, and re-build. The code APIs themselves are fully backwards-compatible. Part of: https://github.com/openedx/openedx-core/issues/470
213 lines
8.7 KiB
Python
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 openedx_content-backed
|
|
library. 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]
|
|
])
|