feat: support for syncing units from libraries to courses (#36553)

* 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>
This commit is contained in:
Navin Karkera
2025-04-24 18:41:47 +00:00
committed by GitHub
parent 875158f1ad
commit 1cd73d1b96
36 changed files with 1636 additions and 542 deletions

View File

@@ -1,6 +1,7 @@
"""
Python API for working with content libraries
"""
from .block_metadata import *
from .collections import *
from .containers import *
from .courseware_import import *

View File

@@ -0,0 +1,85 @@
"""
Content libraries API methods related to XBlocks/Components.
These methods don't enforce permissions (only the REST APIs do).
"""
from __future__ import annotations
from dataclasses import dataclass
from django.utils.translation import gettext as _
from opaque_keys.edx.locator import LibraryUsageLocatorV2
from .libraries import (
library_component_usage_key,
PublishableItem,
)
# The public API is only the following symbols:
__all__ = [
"LibraryXBlockMetadata",
"LibraryXBlockStaticFile",
]
@dataclass(frozen=True, kw_only=True)
class LibraryXBlockMetadata(PublishableItem):
"""
Class that represents the metadata about an XBlock in a content library.
"""
usage_key: LibraryUsageLocatorV2
# TODO: move tags_count to LibraryItem as all objects under a library can be tagged.
tags_count: int = 0
@classmethod
def from_component(cls, library_key, component, associated_collections=None):
"""
Construct a LibraryXBlockMetadata from a Component object.
"""
# Import content_tagging.api here to avoid circular imports
from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts
last_publish_log = component.versioning.last_publish_log
published_by = None
if last_publish_log and last_publish_log.published_by:
published_by = last_publish_log.published_by.username
draft = component.versioning.draft
published = component.versioning.published
last_draft_created = draft.created if draft else None
last_draft_created_by = draft.publishable_entity_version.created_by if draft else None
usage_key = library_component_usage_key(library_key, component)
tags = get_object_tag_counts(str(usage_key), count_implicit=True)
return cls(
usage_key=library_component_usage_key(
library_key,
component,
),
display_name=draft.title,
created=component.created,
modified=draft.created,
draft_version_num=draft.version_num,
published_version_num=published.version_num if published else None,
last_published=None if last_publish_log is None else last_publish_log.published_at,
published_by=published_by,
last_draft_created=last_draft_created,
last_draft_created_by=last_draft_created_by,
has_unpublished_changes=component.versioning.has_unpublished_changes,
collections=associated_collections or [],
tags_count=tags.get(str(usage_key), 0),
can_stand_alone=component.publishable_entity.can_stand_alone,
)
@dataclass(frozen=True)
class LibraryXBlockStaticFile:
"""
Class that represents a static file in a content library, associated with
a particular XBlock.
"""
# File path e.g. "diagram.png"
# In some rare cases it might contain a folder part, e.g. "en/track1.srt"
path: str
# Publicly accessible URL where the file can be downloaded
url: str
# Size in bytes
size: int

View File

@@ -6,7 +6,6 @@ These methods don't enforce permissions (only the REST APIs do).
from __future__ import annotations
import logging
import mimetypes
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import uuid4
@@ -55,6 +54,7 @@ from .exceptions import (
InvalidNameError,
LibraryBlockAlreadyExists,
)
from .block_metadata import LibraryXBlockMetadata, LibraryXBlockStaticFile
from .containers import (
create_container,
get_container,
@@ -65,7 +65,6 @@ from .containers import (
)
from .libraries import (
library_collection_locator,
library_component_usage_key,
PublishableItem,
)
@@ -79,9 +78,6 @@ log = logging.getLogger(__name__)
# The public API is only the following symbols:
__all__ = [
# Models
"LibraryXBlockMetadata",
"LibraryXBlockStaticFile",
# API methods
"get_library_components",
"get_library_block",
@@ -100,63 +96,6 @@ __all__ = [
]
@dataclass(frozen=True, kw_only=True)
class LibraryXBlockMetadata(PublishableItem):
"""
Class that represents the metadata about an XBlock in a content library.
"""
usage_key: LibraryUsageLocatorV2
@classmethod
def from_component(cls, library_key, component, associated_collections=None):
"""
Construct a LibraryXBlockMetadata from a Component object.
"""
last_publish_log = component.versioning.last_publish_log
published_by = None
if last_publish_log and last_publish_log.published_by:
published_by = last_publish_log.published_by.username
draft = component.versioning.draft
published = component.versioning.published
last_draft_created = draft.created if draft else None
last_draft_created_by = draft.publishable_entity_version.created_by if draft else None
return cls(
usage_key=library_component_usage_key(
library_key,
component,
),
display_name=draft.title,
created=component.created,
modified=draft.created,
draft_version_num=draft.version_num,
published_version_num=published.version_num if published else None,
last_published=None if last_publish_log is None else last_publish_log.published_at,
published_by=published_by,
last_draft_created=last_draft_created,
last_draft_created_by=last_draft_created_by,
has_unpublished_changes=component.versioning.has_unpublished_changes,
collections=associated_collections or [],
)
@dataclass(frozen=True)
class LibraryXBlockStaticFile:
"""
Class that represents a static file in a content library, associated with
a particular XBlock.
"""
# File path e.g. "diagram.png"
# In some rare cases it might contain a folder part, e.g. "en/track1.srt"
path: str
# Publicly accessible URL where the file can be downloaded
url: str
# Size in bytes
size: int
def get_library_components(
library_key: LibraryLocatorV2,
text_search: str | None = None,

View File

@@ -34,7 +34,8 @@ from openedx.core.djangoapps.xblock.api import get_component_from_usage_key
from ..models import ContentLibrary
from .exceptions import ContentLibraryContainerNotFound
from .libraries import LibraryXBlockMetadata, PublishableItem, library_component_usage_key
from .libraries import PublishableItem, library_component_usage_key
from .block_metadata import LibraryXBlockMetadata
# The public API is only the following symbols:
__all__ = [
@@ -342,7 +343,7 @@ def restore_container(container_key: LibraryContainerLocator) -> None:
LIBRARY_CONTAINER_CREATED.send_event(
library_container=LibraryContainerData(
container_key=str(container_key),
container_key=container_key,
)
)
@@ -372,7 +373,7 @@ def restore_container(container_key: LibraryContainerLocator) -> None:
def get_container_children(
container_key: LibraryContainerLocator,
published=False,
) -> list[authoring_api.ContainerEntityListEntry]:
) -> list[LibraryXBlockMetadata | ContainerMetadata]:
"""
Get the entities contained in the given container (e.g. the components/xblocks in a unit)
"""

View File

@@ -205,54 +205,6 @@ class PublishableItem(LibraryItem):
can_stand_alone: bool = True
@dataclass(frozen=True, kw_only=True)
class LibraryXBlockMetadata(PublishableItem):
"""
Class that represents the metadata about an XBlock in a content library.
"""
usage_key: LibraryUsageLocatorV2
# TODO: move tags_count to LibraryItem as all objects under a library can be tagged.
tags_count: int = 0
@classmethod
def from_component(cls, library_key, component, associated_collections=None):
"""
Construct a LibraryXBlockMetadata from a Component object.
"""
# Import content_tagging.api here to avoid circular imports
from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts
last_publish_log = component.versioning.last_publish_log
published_by = None
if last_publish_log and last_publish_log.published_by:
published_by = last_publish_log.published_by.username
draft = component.versioning.draft
published = component.versioning.published
last_draft_created = draft.created if draft else None
last_draft_created_by = draft.publishable_entity_version.created_by if draft else None
usage_key = library_component_usage_key(library_key, component)
tags = get_object_tag_counts(str(usage_key), count_implicit=True)
return cls(
usage_key=usage_key,
display_name=draft.title,
created=component.created,
modified=draft.created,
draft_version_num=draft.version_num,
published_version_num=published.version_num if published else None,
last_published=None if last_publish_log is None else last_publish_log.published_at,
published_by=published_by,
last_draft_created=last_draft_created,
last_draft_created_by=last_draft_created_by,
has_unpublished_changes=component.versioning.has_unpublished_changes,
collections=associated_collections or [],
can_stand_alone=component.publishable_entity.can_stand_alone,
tags_count=tags.get(str(usage_key), 0),
)
@dataclass(frozen=True)
class LibraryXBlockStaticFile:
"""

View File

@@ -0,0 +1,4 @@
"""
Python API for testing content libraries
"""
from .base import *

View File

@@ -2,6 +2,7 @@
Tests for Learning-Core-based Content Libraries
"""
from contextlib import contextmanager
import json
from io import BytesIO
from urllib.parse import urlencode
@@ -9,6 +10,7 @@ from organizations.models import Organization
from rest_framework.test import APITransactionTestCase, APIClient
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.util.json_request import JsonResponse as SpecialJsonResponse
from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED
from openedx.core.djangolib.testing.utils import skip_unless_cms
@@ -113,6 +115,8 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
response = getattr(self.client, method)(url, data, format="json")
assert response.status_code == expect_response,\
'Unexpected response code {}:\n{}'.format(response.status_code, getattr(response, 'data', '(no data)'))
if isinstance(response, SpecialJsonResponse): # Required for some old APIs in the CMS that aren't using DRF
return json.loads(response.content)
return response.data
@contextmanager

View File

@@ -380,7 +380,7 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest):
"signal": LIBRARY_CONTAINER_CREATED,
"sender": None,
"library_container": LibraryContainerData(
container_key="lct:CL-TEST:containers:unit:u1",
container_key=LibraryContainerLocator.from_string("lct:CL-TEST:containers:unit:u1"),
),
},
create_receiver.call_args_list[0].kwargs,