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:
@@ -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 *
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Python API for testing content libraries
|
||||
"""
|
||||
from .base import *
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user