feat: collections support for containers [FC-0083] (#36504)
Adds support for adding Containers to Collections.
This commit is contained in:
@@ -3,52 +3,46 @@ Content libraries API methods related to XBlocks/Components.
|
||||
|
||||
These methods don't enforce permissions (only the REST APIs do).
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.validators import validate_unicode_slug
|
||||
from django.db.models import QuerySet
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from lxml import etree
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from opaque_keys.edx.keys import UsageKeyV2
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from openedx_events.content_authoring.data import (
|
||||
ContentObjectChangedData,
|
||||
LibraryBlockData,
|
||||
LibraryCollectionData,
|
||||
LibraryContainerData,
|
||||
ContentObjectChangedData,
|
||||
LibraryContainerData
|
||||
)
|
||||
from openedx_events.content_authoring.signals import (
|
||||
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
|
||||
LIBRARY_BLOCK_CREATED,
|
||||
LIBRARY_BLOCK_DELETED,
|
||||
LIBRARY_BLOCK_UPDATED,
|
||||
LIBRARY_COLLECTION_UPDATED,
|
||||
LIBRARY_CONTAINER_UPDATED,
|
||||
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
|
||||
LIBRARY_CONTAINER_UPDATED
|
||||
)
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx_learning.api.authoring_models import Component, ComponentVersion, LearningPackage, MediaType
|
||||
from xblock.core import XBlock
|
||||
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx_learning.api.authoring_models import (
|
||||
Component,
|
||||
ComponentVersion,
|
||||
LearningPackage,
|
||||
MediaType,
|
||||
)
|
||||
|
||||
from openedx.core.djangoapps.content_libraries import api as lib_api
|
||||
from openedx.core.djangoapps.xblock.api import (
|
||||
get_component_from_usage_key,
|
||||
get_xblock_app_config,
|
||||
xblock_type_display_name,
|
||||
xblock_type_display_name
|
||||
)
|
||||
from openedx.core.types import User as UserType
|
||||
from openedx.core.djangoapps.content_libraries import api as lib_api
|
||||
|
||||
from ..models import ContentLibrary
|
||||
from ..permissions import CAN_EDIT_THIS_CONTENT_LIBRARY
|
||||
|
||||
@@ -3,35 +3,28 @@ Python API for library collections
|
||||
==================================
|
||||
"""
|
||||
from django.db import IntegrityError
|
||||
from opaque_keys import OpaqueKey
|
||||
from opaque_keys.edx.keys import BlockTypeKey, UsageKeyV2
|
||||
from opaque_keys.edx.locator import (
|
||||
LibraryLocatorV2,
|
||||
LibraryCollectionLocator,
|
||||
)
|
||||
|
||||
from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator, LibraryLocatorV2
|
||||
from openedx_events.content_authoring.data import LibraryCollectionData
|
||||
from openedx_events.content_authoring.signals import LIBRARY_COLLECTION_UPDATED
|
||||
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx_learning.api.authoring_models import (
|
||||
Collection,
|
||||
Component,
|
||||
PublishableEntity,
|
||||
)
|
||||
from openedx_learning.api.authoring_models import Collection, Component, PublishableEntity
|
||||
|
||||
from ..models import ContentLibrary
|
||||
from .exceptions import (
|
||||
ContentLibraryBlockNotFound,
|
||||
ContentLibraryCollectionNotFound,
|
||||
ContentLibraryContainerNotFound,
|
||||
LibraryCollectionAlreadyExists,
|
||||
)
|
||||
from ..models import ContentLibrary
|
||||
|
||||
# The public API is only the following symbols:
|
||||
__all__ = [
|
||||
"create_library_collection",
|
||||
"update_library_collection",
|
||||
"update_library_collection_components",
|
||||
"set_library_component_collections",
|
||||
"update_library_collection_items",
|
||||
"set_library_item_collections",
|
||||
"library_collection_locator",
|
||||
"get_library_collection_from_locator",
|
||||
]
|
||||
@@ -103,27 +96,28 @@ def update_library_collection(
|
||||
return collection
|
||||
|
||||
|
||||
def update_library_collection_components(
|
||||
def update_library_collection_items(
|
||||
library_key: LibraryLocatorV2,
|
||||
collection_key: str,
|
||||
*,
|
||||
usage_keys: list[UsageKeyV2],
|
||||
opaque_keys: list[OpaqueKey],
|
||||
created_by: int | None = None,
|
||||
remove=False,
|
||||
# As an optimization, callers may pass in a pre-fetched ContentLibrary instance
|
||||
content_library: ContentLibrary | None = None,
|
||||
) -> Collection:
|
||||
"""
|
||||
Associates the Collection with Components for the given UsageKeys.
|
||||
Associates the Collection with items (XBlocks, Containers) for the given OpaqueKeys.
|
||||
|
||||
By default the Components are added to the Collection.
|
||||
If remove=True, the Components are removed from the Collection.
|
||||
By default the items are added to the Collection.
|
||||
If remove=True, the items are removed from the Collection.
|
||||
|
||||
If you've already fetched the ContentLibrary, pass it in to avoid refetching.
|
||||
|
||||
Raises:
|
||||
* ContentLibraryCollectionNotFound if no Collection with the given pk is found in the given library.
|
||||
* ContentLibraryBlockNotFound if any of the given usage_keys don't match Components in the given library.
|
||||
* ContentLibraryBlockNotFound if any of the given opaque_keys don't match Components in the given library.
|
||||
* ContentLibraryContainerNotFound if any of the given opaque_keys don't match Containers in the given library.
|
||||
|
||||
Returns the updated Collection.
|
||||
"""
|
||||
@@ -134,26 +128,38 @@ def update_library_collection_components(
|
||||
assert content_library.library_key == library_key
|
||||
|
||||
# Fetch the Component.key values for the provided UsageKeys.
|
||||
component_keys = []
|
||||
for usage_key in usage_keys:
|
||||
# Parse the block_family from the key to use as namespace.
|
||||
block_type = BlockTypeKey.from_string(str(usage_key))
|
||||
item_keys = []
|
||||
for opaque_key in opaque_keys:
|
||||
if isinstance(opaque_key, LibraryContainerLocator):
|
||||
try:
|
||||
container = authoring_api.get_container_by_key(
|
||||
content_library.learning_package_id,
|
||||
key=opaque_key.container_id,
|
||||
)
|
||||
except Collection.DoesNotExist as exc:
|
||||
raise ContentLibraryContainerNotFound(opaque_key) from exc
|
||||
|
||||
try:
|
||||
component = authoring_api.get_component_by_key(
|
||||
content_library.learning_package_id,
|
||||
namespace=block_type.block_family,
|
||||
type_name=usage_key.block_type,
|
||||
local_key=usage_key.block_id,
|
||||
)
|
||||
except Component.DoesNotExist as exc:
|
||||
raise ContentLibraryBlockNotFound(usage_key) from exc
|
||||
item_keys.append(container.key)
|
||||
elif isinstance(opaque_key, UsageKeyV2):
|
||||
# Parse the block_family from the key to use as namespace.
|
||||
block_type = BlockTypeKey.from_string(str(opaque_key))
|
||||
try:
|
||||
component = authoring_api.get_component_by_key(
|
||||
content_library.learning_package_id,
|
||||
namespace=block_type.block_family,
|
||||
type_name=opaque_key.block_type,
|
||||
local_key=opaque_key.block_id,
|
||||
)
|
||||
except Component.DoesNotExist as exc:
|
||||
raise ContentLibraryBlockNotFound(opaque_key) from exc
|
||||
|
||||
component_keys.append(component.key)
|
||||
item_keys.append(component.key)
|
||||
else:
|
||||
# This should never happen, but just in case.
|
||||
raise ValueError(f"Invalid opaque_key: {opaque_key}")
|
||||
|
||||
# Note: Component.key matches its PublishableEntity.key
|
||||
entities_qset = PublishableEntity.objects.filter(
|
||||
key__in=component_keys,
|
||||
key__in=item_keys,
|
||||
)
|
||||
|
||||
if remove:
|
||||
@@ -173,19 +179,19 @@ def update_library_collection_components(
|
||||
return collection
|
||||
|
||||
|
||||
def set_library_component_collections(
|
||||
def set_library_item_collections(
|
||||
library_key: LibraryLocatorV2,
|
||||
component: Component,
|
||||
publishable_entity: PublishableEntity,
|
||||
*,
|
||||
collection_keys: list[str],
|
||||
created_by: int | None = None,
|
||||
# As an optimization, callers may pass in a pre-fetched ContentLibrary instance
|
||||
content_library: ContentLibrary | None = None,
|
||||
) -> Component:
|
||||
) -> PublishableEntity:
|
||||
"""
|
||||
It Associates the component with collections for the given collection keys.
|
||||
It Associates the publishable_entity with collections for the given collection keys.
|
||||
|
||||
Only collections in queryset are associated with component, all previous component-collections
|
||||
Only collections in queryset are associated with publishable_entity, all previous publishable_entity-collections
|
||||
associations are removed.
|
||||
|
||||
If you've already fetched the ContentLibrary, pass it in to avoid refetching.
|
||||
@@ -193,7 +199,7 @@ def set_library_component_collections(
|
||||
Raises:
|
||||
* ContentLibraryCollectionNotFound if any of the given collection_keys don't match Collections in the given library.
|
||||
|
||||
Returns the updated Component.
|
||||
Returns the updated PublishableEntity.
|
||||
"""
|
||||
if not content_library:
|
||||
content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
|
||||
@@ -207,8 +213,7 @@ def set_library_component_collections(
|
||||
)
|
||||
|
||||
affected_collections = authoring_api.set_collections(
|
||||
content_library.learning_package_id,
|
||||
component,
|
||||
publishable_entity,
|
||||
collection_qs,
|
||||
created_by=created_by,
|
||||
)
|
||||
@@ -226,7 +231,7 @@ def set_library_component_collections(
|
||||
)
|
||||
)
|
||||
|
||||
return component
|
||||
return publishable_entity
|
||||
|
||||
|
||||
def library_collection_locator(
|
||||
|
||||
@@ -9,20 +9,19 @@ from enum import Enum
|
||||
from uuid import uuid4
|
||||
|
||||
from django.utils.text import slugify
|
||||
from opaque_keys.edx.locator import (
|
||||
LibraryContainerLocator,
|
||||
LibraryLocatorV2,
|
||||
UsageKeyV2,
|
||||
LibraryUsageLocatorV2,
|
||||
)
|
||||
from openedx_events.content_authoring.data import LibraryContainerData
|
||||
from opaque_keys.edx.keys import UsageKeyV2
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData, LibraryContainerData
|
||||
from openedx_events.content_authoring.signals import (
|
||||
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
|
||||
LIBRARY_COLLECTION_UPDATED,
|
||||
LIBRARY_CONTAINER_CREATED,
|
||||
LIBRARY_CONTAINER_DELETED,
|
||||
LIBRARY_CONTAINER_UPDATED,
|
||||
)
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx_learning.api.authoring_models import Container
|
||||
from openedx.core.djangoapps.content_libraries.api.collections import library_collection_locator
|
||||
|
||||
from openedx.core.djangoapps.xblock.api import get_component_from_usage_key
|
||||
|
||||
@@ -30,13 +29,13 @@ from ..models import ContentLibrary
|
||||
from .exceptions import ContentLibraryContainerNotFound
|
||||
from .libraries import LibraryXBlockMetadata, PublishableItem
|
||||
|
||||
|
||||
# The public API is only the following symbols:
|
||||
__all__ = [
|
||||
# Models
|
||||
"ContainerMetadata",
|
||||
"ContainerType",
|
||||
# API methods
|
||||
"get_container_from_key",
|
||||
"get_container",
|
||||
"create_container",
|
||||
"get_container_children",
|
||||
@@ -122,7 +121,7 @@ def library_container_locator(
|
||||
)
|
||||
|
||||
|
||||
def _get_container(container_key: LibraryContainerLocator, isDeleted=False) -> Container:
|
||||
def get_container_from_key(container_key: LibraryContainerLocator, isDeleted=False) -> Container:
|
||||
"""
|
||||
Internal method to fetch the Container object from its LibraryContainerLocator
|
||||
|
||||
@@ -141,12 +140,23 @@ def _get_container(container_key: LibraryContainerLocator, isDeleted=False) -> C
|
||||
raise ContentLibraryContainerNotFound
|
||||
|
||||
|
||||
def get_container(container_key: LibraryContainerLocator) -> ContainerMetadata:
|
||||
def get_container(container_key: LibraryContainerLocator, include_collections=False) -> ContainerMetadata:
|
||||
"""
|
||||
Get a container (a Section, Subsection, or Unit).
|
||||
"""
|
||||
container = _get_container(container_key)
|
||||
container_meta = ContainerMetadata.from_container(container_key.library_key, container)
|
||||
container = get_container_from_key(container_key)
|
||||
if include_collections:
|
||||
associated_collections = authoring_api.get_entity_collections(
|
||||
container.publishable_entity.learning_package_id,
|
||||
container_key.container_id,
|
||||
).values('key', 'title')
|
||||
else:
|
||||
associated_collections = None
|
||||
container_meta = ContainerMetadata.from_container(
|
||||
container_key.library_key,
|
||||
container,
|
||||
associated_collections=associated_collections,
|
||||
)
|
||||
assert container_meta.container_type.value == container_key.container_type
|
||||
return container_meta
|
||||
|
||||
@@ -205,7 +215,7 @@ def update_container(
|
||||
"""
|
||||
Update a container (e.g. a Unit) title.
|
||||
"""
|
||||
container = _get_container(container_key)
|
||||
container = get_container_from_key(container_key)
|
||||
library_key = container_key.library_key
|
||||
|
||||
assert container.unit
|
||||
@@ -233,8 +243,13 @@ def delete_container(
|
||||
|
||||
No-op if container doesn't exist or has already been soft-deleted.
|
||||
"""
|
||||
container = _get_container(container_key)
|
||||
library_key = container_key.library_key
|
||||
container = get_container_from_key(container_key)
|
||||
|
||||
affected_collections = authoring_api.get_entity_collections(
|
||||
container.publishable_entity.learning_package_id,
|
||||
container.key,
|
||||
)
|
||||
authoring_api.soft_delete_draft(container.pk)
|
||||
|
||||
LIBRARY_CONTAINER_DELETED.send_event(
|
||||
@@ -243,14 +258,33 @@ def delete_container(
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: trigger a LIBRARY_COLLECTION_UPDATED for each collection the container was in
|
||||
# For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
|
||||
# collection indexing asynchronously.
|
||||
#
|
||||
# To delete the container on collections
|
||||
for collection in affected_collections:
|
||||
LIBRARY_COLLECTION_UPDATED.send_event(
|
||||
library_collection=LibraryCollectionData(
|
||||
collection_key=library_collection_locator(
|
||||
library_key=library_key,
|
||||
collection_key=collection.key,
|
||||
),
|
||||
background=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def restore_container(container_key: LibraryContainerLocator) -> None:
|
||||
"""
|
||||
Restore the specified library container.
|
||||
"""
|
||||
container = _get_container(container_key, isDeleted=True)
|
||||
library_key = container_key.library_key
|
||||
container = get_container_from_key(container_key, isDeleted=True)
|
||||
|
||||
affected_collections = authoring_api.get_entity_collections(
|
||||
container.publishable_entity.learning_package_id,
|
||||
container.key,
|
||||
)
|
||||
|
||||
authoring_api.set_draft_version(container.pk, container.versioning.latest.pk)
|
||||
|
||||
@@ -260,6 +294,28 @@ def restore_container(container_key: LibraryContainerLocator) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# Add tags and collections back to index
|
||||
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
|
||||
content_object=ContentObjectChangedData(
|
||||
object_id=str(container_key),
|
||||
changes=["collections", "tags"],
|
||||
),
|
||||
)
|
||||
|
||||
# For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
|
||||
# collection indexing asynchronously.
|
||||
#
|
||||
# To restore the container on collections
|
||||
for collection in affected_collections:
|
||||
LIBRARY_COLLECTION_UPDATED.send_event(
|
||||
library_collection=LibraryCollectionData(
|
||||
collection_key=library_collection_locator(
|
||||
library_key=library_key,
|
||||
collection_key=collection.key,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_container_children(
|
||||
container_key: LibraryContainerLocator,
|
||||
@@ -268,7 +324,7 @@ def get_container_children(
|
||||
"""
|
||||
Get the entities contained in the given container (e.g. the components/xblocks in a unit)
|
||||
"""
|
||||
container = _get_container(container_key)
|
||||
container = get_container_from_key(container_key)
|
||||
if container_key.container_type == ContainerType.Unit.value:
|
||||
child_components = authoring_api.get_components_in_unit(container.unit, published=published)
|
||||
return [LibraryXBlockMetadata.from_component(
|
||||
@@ -290,7 +346,7 @@ def get_container_children_count(
|
||||
"""
|
||||
Get the count of entities contained in the given container (e.g. the components/xblocks in a unit)
|
||||
"""
|
||||
container = _get_container(container_key)
|
||||
container = get_container_from_key(container_key)
|
||||
return authoring_api.get_container_children_count(container, published=published)
|
||||
|
||||
|
||||
@@ -305,7 +361,7 @@ def update_container_children(
|
||||
"""
|
||||
library_key = container_key.library_key
|
||||
container_type = container_key.container_type
|
||||
container = _get_container(container_key)
|
||||
container = get_container_from_key(container_key)
|
||||
match container_type:
|
||||
case ContainerType.Unit.value:
|
||||
components = [get_component_from_usage_key(key) for key in children_ids] # type: ignore[arg-type]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""
|
||||
Content Library REST APIs related to XBlocks/Components and their static assets
|
||||
"""
|
||||
import edx_api_doc_tools as apidocs
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.transaction import non_atomic_requests
|
||||
from django.http import Http404, HttpResponse, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.generics import GenericAPIView
|
||||
@@ -14,22 +17,18 @@ from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import edx_api_doc_tools as apidocs
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
|
||||
from openedx.core.djangoapps.content_libraries import api, permissions
|
||||
from openedx.core.djangoapps.content_libraries.rest_api.serializers import (
|
||||
ContentLibraryComponentCollectionsUpdateSerializer,
|
||||
ContentLibraryItemCollectionsUpdateSerializer,
|
||||
LibraryXBlockCreationSerializer,
|
||||
LibraryXBlockMetadataSerializer,
|
||||
LibraryXBlockOlxSerializer,
|
||||
LibraryXBlockStaticFileSerializer,
|
||||
LibraryXBlockStaticFilesSerializer,
|
||||
)
|
||||
from openedx.core.djangoapps.xblock import api as xblock_api
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from openedx.core.types.http import RestRequest
|
||||
from openedx.core.djangoapps.xblock import api as xblock_api
|
||||
|
||||
from .libraries import LibraryApiPaginationDocs
|
||||
from .utils import convert_exceptions
|
||||
@@ -259,13 +258,13 @@ class LibraryBlockCollectionsView(APIView):
|
||||
permissions.CAN_EDIT_THIS_CONTENT_LIBRARY
|
||||
)
|
||||
component = api.get_component_from_usage_key(key)
|
||||
serializer = ContentLibraryComponentCollectionsUpdateSerializer(data=request.data)
|
||||
serializer = ContentLibraryItemCollectionsUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
collection_keys = serializer.validated_data['collection_keys']
|
||||
api.set_library_component_collections(
|
||||
api.set_library_item_collections(
|
||||
library_key=key.lib_key,
|
||||
component=component,
|
||||
publishable_entity=component.publishable_entity,
|
||||
collection_keys=collection_keys,
|
||||
created_by=request.user.id,
|
||||
content_library=content_library,
|
||||
|
||||
@@ -22,7 +22,7 @@ from .utils import convert_exceptions
|
||||
from .serializers import (
|
||||
ContentLibraryCollectionSerializer,
|
||||
ContentLibraryCollectionUpdateSerializer,
|
||||
ContentLibraryComponentKeysSerializer,
|
||||
ContentLibraryItemKeysSerializer,
|
||||
)
|
||||
from openedx.core.types.http import RestRequest
|
||||
|
||||
@@ -190,27 +190,27 @@ class LibraryCollectionsView(ModelViewSet):
|
||||
return Response(None, status=HTTP_204_NO_CONTENT)
|
||||
|
||||
@convert_exceptions
|
||||
@action(detail=True, methods=['delete', 'patch'], url_path='components', url_name='components-update')
|
||||
def update_components(self, request: RestRequest, *args, **kwargs) -> Response:
|
||||
@action(detail=True, methods=['delete', 'patch'], url_path='items', url_name='items-update')
|
||||
def update_items(self, request: RestRequest, *args, **kwargs) -> Response:
|
||||
"""
|
||||
Adds (PATCH) or removes (DELETE) Components to/from a Collection.
|
||||
Adds (PATCH) or removes (DELETE) items to/from a Collection.
|
||||
|
||||
Collection and Components must all be part of the given library/learning package.
|
||||
Collection and items must all be part of the given library/learning package.
|
||||
"""
|
||||
content_library = self.get_content_library()
|
||||
collection_key = kwargs["key"]
|
||||
|
||||
serializer = ContentLibraryComponentKeysSerializer(data=request.data)
|
||||
serializer = ContentLibraryItemKeysSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
usage_keys = serializer.validated_data["usage_keys"]
|
||||
api.update_library_collection_components(
|
||||
opaque_keys = serializer.validated_data["usage_keys"]
|
||||
api.update_library_collection_items(
|
||||
library_key=content_library.library_key,
|
||||
content_library=content_library,
|
||||
collection_key=collection_key,
|
||||
usage_keys=usage_keys,
|
||||
opaque_keys=opaque_keys,
|
||||
created_by=request.user.id,
|
||||
remove=(request.method == "DELETE"),
|
||||
)
|
||||
|
||||
return Response({'count': len(usage_keys)})
|
||||
return Response({'count': len(opaque_keys)})
|
||||
|
||||
@@ -18,6 +18,7 @@ from rest_framework.status import HTTP_204_NO_CONTENT
|
||||
|
||||
from openedx.core.djangoapps.content_libraries import api, permissions
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from openedx.core.types.http import RestRequest
|
||||
from . import serializers
|
||||
from .utils import convert_exceptions
|
||||
|
||||
@@ -80,7 +81,7 @@ class LibraryContainerView(GenericAPIView):
|
||||
request.user,
|
||||
permissions.CAN_VIEW_THIS_CONTENT_LIBRARY,
|
||||
)
|
||||
container = api.get_container(container_key)
|
||||
container = api.get_container(container_key, include_collections=True)
|
||||
return Response(serializers.LibraryContainerMetadataSerializer(container).data)
|
||||
|
||||
@convert_exceptions
|
||||
@@ -293,3 +294,37 @@ class LibraryContainerRestore(GenericAPIView):
|
||||
)
|
||||
api.restore_container(container_key)
|
||||
return Response(None, status=HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@method_decorator(non_atomic_requests, name="dispatch")
|
||||
@view_auth_classes()
|
||||
class LibraryContainerCollectionsView(GenericAPIView):
|
||||
"""
|
||||
View to set collections for a container.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def patch(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response:
|
||||
"""
|
||||
Sets Collections for a Component.
|
||||
|
||||
Collection and Components must all be part of the given library/learning package.
|
||||
"""
|
||||
content_library = api.require_permission_for_library_key(
|
||||
container_key.library_key,
|
||||
request.user,
|
||||
permissions.CAN_EDIT_THIS_CONTENT_LIBRARY
|
||||
)
|
||||
container = api.get_container_from_key(container_key)
|
||||
serializer = serializers.ContentLibraryItemCollectionsUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
collection_keys = serializer.validated_data['collection_keys']
|
||||
api.set_library_item_collections(
|
||||
library_key=container_key.library_key,
|
||||
publishable_entity=container.publishable_entity,
|
||||
collection_keys=collection_keys,
|
||||
created_by=request.user.id,
|
||||
content_library=content_library,
|
||||
)
|
||||
|
||||
return Response({'count': len(collection_keys)})
|
||||
|
||||
@@ -6,7 +6,9 @@ from django.core.validators import validate_unicode_slug
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from opaque_keys import OpaqueKey
|
||||
from opaque_keys.edx.keys import UsageKeyV2
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
from openedx_learning.api.authoring_models import Collection
|
||||
@@ -357,9 +359,42 @@ class ContentLibraryComponentKeysSerializer(serializers.Serializer):
|
||||
usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False)
|
||||
|
||||
|
||||
class ContentLibraryComponentCollectionsUpdateSerializer(serializers.Serializer):
|
||||
class OpaqueKeySerializer(serializers.BaseSerializer):
|
||||
"""
|
||||
Serializer for adding/removing Collections to/from a Component.
|
||||
Serializes a OpaqueKey with the correct class.
|
||||
"""
|
||||
def to_representation(self, value: OpaqueKey) -> str:
|
||||
"""
|
||||
Returns the OpaqueKey value as a string.
|
||||
"""
|
||||
return str(value)
|
||||
|
||||
def to_internal_value(self, value: str) -> OpaqueKey:
|
||||
"""
|
||||
Returns a UsageKeyV2 or a LibraryContainerLocator from the string value.
|
||||
|
||||
Raises ValidationError if invalid UsageKeyV2 or LibraryContainerLocator.
|
||||
"""
|
||||
try:
|
||||
return UsageKeyV2.from_string(value)
|
||||
except InvalidKeyError:
|
||||
try:
|
||||
return LibraryContainerLocator.from_string(value)
|
||||
except InvalidKeyError as err:
|
||||
raise ValidationError from err
|
||||
|
||||
|
||||
class ContentLibraryItemKeysSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for adding/removing items to/from a Collection.
|
||||
"""
|
||||
|
||||
usage_keys = serializers.ListField(child=OpaqueKeySerializer(), allow_empty=False)
|
||||
|
||||
|
||||
class ContentLibraryItemCollectionsUpdateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for adding/removing Collections to/from a Library Item (component, unit, etc..).
|
||||
"""
|
||||
|
||||
collection_keys = serializers.ListField(child=serializers.CharField(), allow_empty=True)
|
||||
|
||||
@@ -7,25 +7,21 @@ import logging
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import m2m_changed, post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys import InvalidKeyError, OpaqueKey
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from openedx_events.content_authoring.data import (
|
||||
ContentObjectChangedData,
|
||||
LibraryCollectionData,
|
||||
)
|
||||
from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData
|
||||
from openedx_events.content_authoring.signals import (
|
||||
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
|
||||
LIBRARY_COLLECTION_CREATED,
|
||||
LIBRARY_COLLECTION_DELETED,
|
||||
LIBRARY_COLLECTION_UPDATED,
|
||||
LIBRARY_COLLECTION_UPDATED
|
||||
)
|
||||
from openedx_learning.api.authoring import get_component, get_components
|
||||
from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, Component, PublishableEntity
|
||||
from openedx_learning.api.authoring import get_components, get_containers
|
||||
from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, PublishableEntity
|
||||
|
||||
from lms.djangoapps.grades.api import signals as grades_signals
|
||||
|
||||
from .api import library_collection_locator, library_component_usage_key
|
||||
from .api import library_collection_locator, library_component_usage_key, library_container_locator
|
||||
from .models import ContentLibrary, LtiGradedResource
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -124,33 +120,45 @@ def library_collection_deleted(sender, instance, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
def _library_collection_component_changed(
|
||||
component: Component,
|
||||
def _library_collection_entity_changed(
|
||||
publishable_entity: PublishableEntity,
|
||||
library_key: LibraryLocatorV2 | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for the component.
|
||||
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for the entity.
|
||||
"""
|
||||
if not library_key:
|
||||
try:
|
||||
library = ContentLibrary.objects.get(
|
||||
learning_package_id=component.learning_package_id,
|
||||
learning_package_id=publishable_entity.learning_package_id,
|
||||
)
|
||||
except ContentLibrary.DoesNotExist:
|
||||
log.error("{component} is not associated with a content library.")
|
||||
log.error("{publishable_entity} is not associated with a content library.")
|
||||
return
|
||||
|
||||
library_key = library.library_key
|
||||
|
||||
assert library_key
|
||||
|
||||
usage_key = library_component_usage_key(
|
||||
library_key,
|
||||
component,
|
||||
)
|
||||
opaque_key: OpaqueKey
|
||||
|
||||
if hasattr(publishable_entity, 'component'):
|
||||
opaque_key = library_component_usage_key(
|
||||
library_key,
|
||||
publishable_entity.component,
|
||||
)
|
||||
elif hasattr(publishable_entity, 'container'):
|
||||
opaque_key = library_container_locator(
|
||||
library_key,
|
||||
publishable_entity.container,
|
||||
)
|
||||
else:
|
||||
log.error("Unknown publishable entity type: %s", publishable_entity)
|
||||
return
|
||||
|
||||
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
|
||||
content_object=ContentObjectChangedData(
|
||||
object_id=str(usage_key),
|
||||
object_id=str(opaque_key),
|
||||
changes=["collections"],
|
||||
),
|
||||
)
|
||||
@@ -162,9 +170,7 @@ def library_collection_entity_saved(sender, instance, created, **kwargs):
|
||||
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added to a collection.
|
||||
"""
|
||||
if created:
|
||||
# Component.pk matches its entity.pk
|
||||
component = get_component(instance.entity_id)
|
||||
_library_collection_component_changed(component)
|
||||
_library_collection_entity_changed(instance.entity)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_deleted")
|
||||
@@ -174,9 +180,7 @@ def library_collection_entity_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
# Only trigger component updates if CollectionPublishableEntity was cascade deleted due to deletion of a collection.
|
||||
if isinstance(kwargs.get('origin'), Collection):
|
||||
# Component.pk matches its entity.pk
|
||||
component = get_component(instance.entity_id)
|
||||
_library_collection_component_changed(component)
|
||||
_library_collection_entity_changed(instance.entity)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entities_changed")
|
||||
@@ -196,15 +200,18 @@ def library_collection_entities_changed(sender, instance, action, pk_set, **kwar
|
||||
return
|
||||
|
||||
if isinstance(instance, PublishableEntity):
|
||||
_library_collection_component_changed(instance.component, library.library_key)
|
||||
_library_collection_entity_changed(instance, library.library_key)
|
||||
return
|
||||
|
||||
# When action=="post_clear", pk_set==None
|
||||
# Since the collection instance now has an empty entities set,
|
||||
# we don't know which ones were removed, so we need to update associations for all library components.
|
||||
# we don't know which ones were removed, so we need to update associations for all library
|
||||
# components and containers.
|
||||
components = get_components(instance.learning_package_id)
|
||||
containers = get_containers(instance.learning_package_id)
|
||||
if pk_set:
|
||||
components = components.filter(pk__in=pk_set)
|
||||
containers = containers.filter(pk__in=pk_set)
|
||||
|
||||
for component in components.all():
|
||||
_library_collection_component_changed(component, library.library_key)
|
||||
for entity in list(components.all()) + list(containers.all()):
|
||||
_library_collection_entity_changed(entity.publishable_entity, library.library_key)
|
||||
|
||||
@@ -35,6 +35,7 @@ URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete,
|
||||
URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library
|
||||
URL_LIB_CONTAINER_COMPONENTS = URL_LIB_CONTAINER + 'children/' # Get, add or delete a component in this container
|
||||
URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container
|
||||
URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections
|
||||
|
||||
URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/'
|
||||
URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
|
||||
@@ -441,3 +442,17 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
|
||||
{'usage_keys': children_ids},
|
||||
expect_response
|
||||
)
|
||||
|
||||
def _patch_container_collections(
|
||||
self,
|
||||
container_key: str,
|
||||
collection_keys: list[str],
|
||||
expect_response=200,
|
||||
):
|
||||
""" Update container collections"""
|
||||
return self._api(
|
||||
'patch',
|
||||
URL_LIB_CONTAINER_COLLECTIONS.format(container_key=container_key),
|
||||
{'collection_keys': collection_keys},
|
||||
expect_response
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from opaque_keys.edx.keys import (
|
||||
CourseKey,
|
||||
UsageKey,
|
||||
)
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
|
||||
from openedx_events.content_authoring.data import (
|
||||
ContentObjectChangedData,
|
||||
LibraryCollectionData,
|
||||
@@ -325,6 +325,12 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
self.lib1_html_block = self._add_block_to_library(
|
||||
self.lib1.library_key, "html", "html1",
|
||||
)
|
||||
# Create a container in lib1
|
||||
self.unit1 = self._create_container(
|
||||
str(self.lib1.library_key),
|
||||
"unit", 'unit-1', 'Unit 1'
|
||||
)
|
||||
|
||||
# Create some library blocks in lib2
|
||||
self.lib2_problem_block = self._add_block_to_library(
|
||||
self.lib2.library_key, "problem", "problem2",
|
||||
@@ -431,28 +437,29 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
event_receiver.call_args_list[0].kwargs,
|
||||
)
|
||||
|
||||
def test_update_library_collection_components(self):
|
||||
def test_update_library_collection_items(self):
|
||||
assert not list(self.col1.entities.all())
|
||||
|
||||
self.col1 = api.update_library_collection_components(
|
||||
self.col1 = api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
LibraryContainerLocator.from_string(self.unit1["container_key"]),
|
||||
],
|
||||
)
|
||||
assert len(self.col1.entities.all()) == 2
|
||||
assert len(self.col1.entities.all()) == 3
|
||||
|
||||
self.col1 = api.update_library_collection_components(
|
||||
self.col1 = api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
],
|
||||
remove=True,
|
||||
)
|
||||
assert len(self.col1.entities.all()) == 1
|
||||
assert len(self.col1.entities.all()) == 2
|
||||
|
||||
def test_update_library_collection_components_event(self):
|
||||
"""
|
||||
@@ -462,16 +469,17 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver)
|
||||
LIBRARY_COLLECTION_UPDATED.connect(event_receiver)
|
||||
|
||||
api.update_library_collection_components(
|
||||
api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
LibraryContainerLocator.from_string(self.unit1["container_key"]),
|
||||
],
|
||||
)
|
||||
|
||||
assert event_receiver.call_count == 3
|
||||
assert event_receiver.call_count == 4
|
||||
self.assertDictContainsSubset(
|
||||
{
|
||||
"signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
|
||||
@@ -494,6 +502,17 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
},
|
||||
event_receiver.call_args_list[1].kwargs,
|
||||
)
|
||||
self.assertDictContainsSubset(
|
||||
{
|
||||
"signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
|
||||
"sender": None,
|
||||
"content_object": ContentObjectChangedData(
|
||||
object_id=self.unit1["container_key"],
|
||||
changes=["collections"],
|
||||
),
|
||||
},
|
||||
event_receiver.call_args_list[2].kwargs,
|
||||
)
|
||||
self.assertDictContainsSubset(
|
||||
{
|
||||
"signal": LIBRARY_COLLECTION_UPDATED,
|
||||
@@ -505,17 +524,18 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
),
|
||||
),
|
||||
},
|
||||
event_receiver.call_args_list[2].kwargs,
|
||||
event_receiver.call_args_list[3].kwargs,
|
||||
)
|
||||
|
||||
def test_update_collection_components_from_wrong_library(self):
|
||||
with self.assertRaises(api.ContentLibraryBlockNotFound) as exc:
|
||||
api.update_library_collection_components(
|
||||
api.update_library_collection_items(
|
||||
self.lib2.library_key,
|
||||
self.col2.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
LibraryContainerLocator.from_string(self.unit1["container_key"]),
|
||||
],
|
||||
)
|
||||
assert self.lib1_problem_block["id"] in str(exc.exception)
|
||||
@@ -528,9 +548,9 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
assert not list(self.col2.entities.all())
|
||||
component = api.get_component_from_usage_key(UsageKey.from_string(self.lib2_problem_block["id"]))
|
||||
|
||||
api.set_library_component_collections(
|
||||
api.set_library_item_collections(
|
||||
self.lib2.library_key,
|
||||
component,
|
||||
component.publishable_entity,
|
||||
collection_keys=[self.col2.key, self.col3.key],
|
||||
)
|
||||
|
||||
@@ -577,10 +597,10 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
)
|
||||
|
||||
def test_delete_library_block(self):
|
||||
api.update_library_collection_components(
|
||||
api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
],
|
||||
@@ -607,11 +627,43 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
event_receiver.call_args_list[0].kwargs,
|
||||
)
|
||||
|
||||
def test_restore_library_block(self):
|
||||
api.update_library_collection_components(
|
||||
def test_delete_library_container(self):
|
||||
api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
LibraryContainerLocator.from_string(self.unit1["container_key"]),
|
||||
],
|
||||
)
|
||||
|
||||
event_receiver = mock.Mock()
|
||||
LIBRARY_COLLECTION_UPDATED.connect(event_receiver)
|
||||
|
||||
api.delete_container(LibraryContainerLocator.from_string(self.unit1["container_key"]))
|
||||
|
||||
assert event_receiver.call_count == 1
|
||||
self.assertDictContainsSubset(
|
||||
{
|
||||
"signal": LIBRARY_COLLECTION_UPDATED,
|
||||
"sender": None,
|
||||
"library_collection": LibraryCollectionData(
|
||||
collection_key=api.library_collection_locator(
|
||||
self.lib1.library_key,
|
||||
collection_key=self.col1.key,
|
||||
),
|
||||
background=True,
|
||||
),
|
||||
},
|
||||
event_receiver.call_args_list[0].kwargs,
|
||||
)
|
||||
|
||||
def test_restore_library_block(self):
|
||||
api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
],
|
||||
@@ -640,20 +692,20 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
|
||||
def test_add_component_and_revert(self):
|
||||
# Add component and publish
|
||||
api.update_library_collection_components(
|
||||
api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
],
|
||||
)
|
||||
api.publish_changes(self.lib1.library_key)
|
||||
|
||||
# Add component and revert
|
||||
api.update_library_collection_components(
|
||||
api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_html_block["id"]),
|
||||
],
|
||||
)
|
||||
@@ -706,10 +758,10 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
|
||||
|
||||
def test_delete_component_and_revert(self):
|
||||
# Add components and publish
|
||||
api.update_library_collection_components(
|
||||
api.update_library_collection_items(
|
||||
self.lib1.library_key,
|
||||
self.col1.key,
|
||||
usage_keys=[
|
||||
opaque_keys=[
|
||||
UsageKey.from_string(self.lib1_problem_block["id"]),
|
||||
UsageKey.from_string(self.lib1_html_block["id"])
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ from openedx_events.content_authoring.signals import (
|
||||
from openedx_events.tests.utils import OpenEdxEventsTestMixin
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from openedx.core.djangoapps.content_libraries import api
|
||||
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_cms
|
||||
|
||||
@@ -382,3 +383,33 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest):
|
||||
},
|
||||
create_receiver.call_args_list[0].kwargs,
|
||||
)
|
||||
|
||||
def test_container_collections(self):
|
||||
# Create a library
|
||||
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
|
||||
lib_key = LibraryLocatorV2.from_string(lib["id"])
|
||||
|
||||
# Create a unit
|
||||
container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None)
|
||||
|
||||
# Create a collection
|
||||
col1 = api.create_library_collection(
|
||||
lib_key,
|
||||
"COL1",
|
||||
title="Collection 1",
|
||||
created_by=self.user.id,
|
||||
description="Description for Collection 1",
|
||||
)
|
||||
|
||||
result = self._patch_container_collections(
|
||||
container_data["container_key"],
|
||||
collection_keys=[col1.key],
|
||||
)
|
||||
|
||||
assert result['count'] == 1
|
||||
|
||||
# Fetch the unit
|
||||
unit_as_read = self._get_container(container_data["container_key"])
|
||||
|
||||
# Verify the collections
|
||||
assert unit_as_read['collections'] == [{"title": col1.title, "key": col1.key}]
|
||||
|
||||
@@ -18,7 +18,7 @@ URL_PREFIX = '/api/libraries/v2/{lib_key}/'
|
||||
URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/'
|
||||
URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/'
|
||||
URL_LIB_COLLECTION_RESTORE = URL_LIB_COLLECTIONS + '{collection_key}/restore/'
|
||||
URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'components/'
|
||||
URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'items/'
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -3,12 +3,10 @@ URL configuration for Studio's Content Libraries REST API
|
||||
"""
|
||||
|
||||
from django.urls import include, path, re_path, register_converter
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from .rest_api import blocks, collections, containers, libraries, url_converters
|
||||
|
||||
|
||||
# Django application name.
|
||||
|
||||
app_name = 'openedx.core.djangoapps.content_libraries'
|
||||
@@ -85,7 +83,7 @@ urlpatterns = [
|
||||
# Restore a soft-deleted container
|
||||
path('restore/', containers.LibraryContainerRestore.as_view()),
|
||||
# Update collections for a given container
|
||||
# path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
|
||||
path('collections/', containers.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
|
||||
# path('publish/', views.LibraryContainerPublishView.as_view()),
|
||||
])),
|
||||
re_path(r'^lti/1.3/', include([
|
||||
|
||||
Reference in New Issue
Block a user