Skip to content

Commit 40f8988

Browse files
feat: new REST API for units in content libraries
1 parent d1b7b6e commit 40f8988

File tree

8 files changed

+314
-6
lines changed

8 files changed

+314
-6
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""
22
Python API for working with content libraries
33
"""
4+
from .containers import *
45
from .libraries import *
56
from .blocks import *
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
API for containers (Sections, Subsections, Units) in Content Libraries
3+
"""
4+
from __future__ import annotations
5+
from dataclasses import dataclass
6+
from datetime import datetime
7+
from enum import Enum
8+
from uuid import uuid4
9+
10+
from django.utils.text import slugify
11+
from opaque_keys.edx.locator import (
12+
LibraryLocatorV2,
13+
LibraryContainerLocator,
14+
)
15+
16+
from openedx_learning.api import authoring as authoring_api
17+
from openedx_learning.api import authoring_models
18+
19+
from ..models import ContentLibrary
20+
from .libraries import PublishableItem
21+
22+
# The public API is only the following symbols:
23+
__all__ = [
24+
"ContainerMetadata",
25+
"create_container",
26+
]
27+
28+
29+
class ContainerType(Enum):
30+
Unit = "unit"
31+
32+
33+
@dataclass(frozen=True, kw_only=True)
34+
class ContainerMetadata(PublishableItem):
35+
"""
36+
Class that represents the metadata about an XBlock in a content library.
37+
"""
38+
container_key: LibraryContainerLocator
39+
container_type: ContainerType
40+
41+
@classmethod
42+
def from_container(cls, library_key, container: authoring_models.Container, associated_collections=None):
43+
"""
44+
Construct a LibraryXBlockMetadata from a Component object.
45+
"""
46+
last_publish_log = container.versioning.last_publish_log
47+
48+
assert container.unit is not None
49+
container_type = ContainerType.Unit
50+
51+
published_by = None
52+
if last_publish_log and last_publish_log.published_by:
53+
published_by = last_publish_log.published_by.username
54+
55+
draft = container.versioning.draft
56+
published = container.versioning.published
57+
last_draft_created = draft.created if draft else None
58+
last_draft_created_by = draft.publishable_entity_version.created_by.username if draft else ""
59+
60+
return cls(
61+
container_key=LibraryContainerLocator(
62+
library_key,
63+
container_type=container_type.value,
64+
container_id=container.publishable_entity.key,
65+
),
66+
container_type=container_type,
67+
display_name=draft.title,
68+
created=container.created,
69+
modified=draft.created,
70+
draft_version_num=draft.version_num,
71+
published_version_num=published.version_num if published else None,
72+
last_published=None if last_publish_log is None else last_publish_log.published_at,
73+
published_by=published_by or "",
74+
last_draft_created=last_draft_created,
75+
last_draft_created_by=last_draft_created_by,
76+
has_unpublished_changes=authoring_api.contains_unpublished_changes(container.pk),
77+
collections=associated_collections or [],
78+
)
79+
80+
81+
def create_container(
82+
library_key: LibraryLocatorV2,
83+
container_type: ContainerType,
84+
slug: str | None,
85+
title: str,
86+
user_id: int | None,
87+
) -> ContainerMetadata:
88+
"""
89+
Create a container (e.g. a Unit) in the specified content library.
90+
91+
It will initially be empty.
92+
"""
93+
assert isinstance(library_key, LibraryLocatorV2)
94+
content_library = ContentLibrary.objects.get_by_key(library_key)
95+
assert content_library.learning_package_id # Should never happen but we made this a nullable field so need to check
96+
if slug is None:
97+
# Automatically generate a slug. Append a random suffix so it should be unique.
98+
slug = slugify(title, allow_unicode=True) + '-' + uuid4().hex[-6:]
99+
# Make sure the slug is valid by first creating a key for the new container:
100+
LibraryContainerLocator(library_key=library_key, container_type=container_type.value, container_id=slug)
101+
# Then try creating the actual container:
102+
match container_type:
103+
case ContainerType.Unit:
104+
container, _initial_version = authoring_api.create_unit_and_version(
105+
content_library.learning_package_id,
106+
key=slug,
107+
title=title,
108+
created=datetime.now(),
109+
created_by=user_id,
110+
)
111+
case _:
112+
raise ValueError(f"Invalid container type: {container_type}")
113+
return ContainerMetadata.from_container(library_key, container)

openedx/core/djangoapps/content_libraries/api/libraries.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,25 +277,40 @@ class CollectionMetadata:
277277

278278

279279
@dataclass(frozen=True)
280-
class LibraryXBlockMetadata:
280+
class LibraryItem:
281281
"""
282-
Class that represents the metadata about an XBlock in a content library.
282+
Common fields for anything that can be found in a content library.
283283
"""
284-
usage_key: LibraryUsageLocatorV2
285284
created: datetime
286285
modified: datetime
286+
display_name: str
287+
288+
289+
@dataclass(frozen=True, kw_only=True)
290+
class PublishableItem(LibraryItem):
291+
"""
292+
Common fields for anything that can be found in a content library that has
293+
draft/publish support.
294+
"""
287295
draft_version_num: int
288296
published_version_num: int | None = None
289-
display_name: str = ""
290297
last_published: datetime | None = None
291-
# THe username of the user who last published this.
298+
# The username of the user who last published this.
292299
published_by: str = ""
293300
last_draft_created: datetime | None = None
294301
# The username of the user who created the last draft.
295302
last_draft_created_by: str = ""
296303
has_unpublished_changes: bool = False
297304
collections: list[CollectionMetadata] = field(default_factory=list)
298305

306+
307+
@dataclass(frozen=True, kw_only=True)
308+
class LibraryXBlockMetadata(PublishableItem):
309+
"""
310+
Class that represents the metadata about an XBlock in a content library.
311+
"""
312+
usage_key: LibraryUsageLocatorV2
313+
299314
@classmethod
300315
def from_component(cls, library_key, component, associated_collections=None):
301316
"""
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
REST API views for containers (sections, subsections, units) in content libraries
3+
"""
4+
from __future__ import annotations
5+
6+
import logging
7+
8+
from django.contrib.auth import get_user_model
9+
from django.db.transaction import non_atomic_requests
10+
from django.utils.decorators import method_decorator
11+
from django.utils.translation import gettext as _
12+
from drf_yasg.utils import swagger_auto_schema
13+
14+
from opaque_keys.edx.locator import LibraryLocatorV2
15+
from rest_framework.generics import GenericAPIView
16+
from rest_framework.response import Response
17+
18+
from openedx.core.djangoapps.content_libraries import api, permissions
19+
from openedx.core.lib.api.view_utils import view_auth_classes
20+
from . import serializers
21+
from .utils import convert_exceptions
22+
23+
User = get_user_model()
24+
log = logging.getLogger(__name__)
25+
26+
27+
@method_decorator(non_atomic_requests, name="dispatch")
28+
@view_auth_classes()
29+
class LibraryContainersView(GenericAPIView):
30+
"""
31+
Views to work with Containers in a specific content library.
32+
"""
33+
serializer_class = serializers.LibraryContainerMetadataSerializer
34+
35+
@convert_exceptions
36+
@swagger_auto_schema(
37+
request_body=serializers.LibraryContainerMetadataSerializer,
38+
responses={200: serializers.LibraryContainerMetadataSerializer}
39+
)
40+
def post(self, request, lib_key_str):
41+
"""
42+
Create a new Container in this content library
43+
"""
44+
library_key = LibraryLocatorV2.from_string(lib_key_str)
45+
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
46+
serializer = serializers.LibraryContainerMetadataSerializer(data=request.data)
47+
serializer.is_valid(raise_exception=True)
48+
49+
container_type = serializer.validated_data['container_type']
50+
container = api.create_container(
51+
library_key,
52+
container_type,
53+
title=serializer.validated_data['display_name'],
54+
slug=serializer.validated_data.get('slug'),
55+
user_id=request.user.id,
56+
)
57+
58+
return Response(serializers.LibraryContainerMetadataSerializer(container).data)

openedx/core/djangoapps/content_libraries/rest_api/serializers.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from opaque_keys import InvalidKeyError
1111

1212
from openedx_learning.api.authoring_models import Collection
13+
from openedx.core.djangoapps.content_libraries.api.containers import ContainerMetadata, ContainerType
1314
from openedx.core.djangoapps.content_libraries.constants import (
1415
ALL_RIGHTS_RESERVED,
1516
LICENSE_OPTIONS,
@@ -230,6 +231,45 @@ class LibraryXBlockStaticFilesSerializer(serializers.Serializer):
230231
files = LibraryXBlockStaticFileSerializer(many=True)
231232

232233

234+
class LibraryContainerMetadataSerializer(serializers.Serializer):
235+
"""
236+
Serializer for Containers like Sections, Subsections, Units
237+
238+
Converts from ContainerMetadata to JSON-compatible data
239+
"""
240+
container_key = serializers.CharField(read_only=True)
241+
container_type = serializers.CharField()
242+
display_name = serializers.CharField()
243+
last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
244+
published_by = serializers.CharField(read_only=True)
245+
last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
246+
last_draft_created_by = serializers.CharField(read_only=True)
247+
has_unpublished_changes = serializers.BooleanField(read_only=True)
248+
created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
249+
modified = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
250+
tags_count = serializers.IntegerField(read_only=True)
251+
collections = CollectionMetadataSerializer(many=True, required=False, read_only=True)
252+
253+
# When creating a new container in a library, the slug becomes the ID part of
254+
# the definition key and usage key:
255+
slug = serializers.CharField(write_only=True)
256+
257+
def to_representation(self, instance: ContainerMetadata):
258+
""" Convert to JSON-serializable data types """
259+
data = super().to_representation(instance)
260+
data["container_type"] = instance.container_type.value # Force to a string, not an enum value instance
261+
return data
262+
263+
def to_internal_value(self, data):
264+
"""
265+
Convert JSON-ish data back to native python types.
266+
Returns a dictionary, not a ContainerMetadata instance.
267+
"""
268+
result = super().to_internal_value(data)
269+
result["container_type"] = ContainerType(data["container_type"])
270+
return result
271+
272+
233273
class ContentLibraryBlockImportTaskSerializer(serializers.ModelSerializer):
234274
"""
235275
Serializer for a Content Library block import task.

openedx/core/djangoapps/content_libraries/tests/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
URL_LIB_LINKS = URL_LIB_DETAIL + 'links/' # Get the list of links in this library, or add a new one
2323
URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library
2424
URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one
25+
URL_LIB_CONTAINERS = URL_LIB_DETAIL + 'containers/' # Create a new container in this library
2526
URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library
2627
URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{username}/' # Add/edit/remove a user's permission to use this library
2728
URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library
@@ -350,3 +351,11 @@ def _get_library_block_fields(self, block_key, version=None, expect_response=200
350351
def _set_library_block_fields(self, block_key, new_fields, expect_response=200):
351352
""" Set the fields of a specific block in the library. This API is only used by the MFE editors. """
352353
return self._api('post', URL_BLOCK_FIELDS_URL.format(block_key=block_key), new_fields, expect_response)
354+
355+
def _create_container(self, lib_key, container_type, slug: str | None, display_name: str, expect_response=200):
356+
""" Create a container (unit etc.) """
357+
return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), {
358+
"container_type": container_type,
359+
"slug": slug,
360+
"display_name": display_name,
361+
}, expect_response)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Tests for Learning-Core-based Content Libraries
3+
"""
4+
from datetime import datetime, timezone
5+
6+
import ddt
7+
from freezegun import freeze_time
8+
9+
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
10+
from openedx.core.djangolib.testing.utils import skip_unless_cms
11+
12+
13+
@skip_unless_cms
14+
@ddt.ddt
15+
class ContainersTestCase(ContentLibrariesRestApiTest):
16+
"""
17+
Tests for containers (Sections, Subsections, Units) in Content Libraries.
18+
19+
These tests use the REST API, which in turn relies on the Python API.
20+
Some tests may use the python API directly if necessary to provide
21+
coverage of any code paths not accessible via the REST API.
22+
23+
In general, these tests should
24+
(1) Use public APIs only - don't directly create data using other methods,
25+
which results in a less realistic test and ties the test suite too
26+
closely to specific implementation details.
27+
(Exception: users can be provisioned using a user factory)
28+
(2) Assert that fields are present in responses, but don't assert that the
29+
entire response has some specific shape. That way, things like adding
30+
new fields to an API response, which are backwards compatible, won't
31+
break any tests, but backwards-incompatible API changes will.
32+
"""
33+
# Note: if we need events at some point, add OpenEdxEventsTestMixin and set the list here; see other test suites.
34+
# ENABLED_OPENEDX_EVENTS = []
35+
36+
def test_unit_crud(self):
37+
"""
38+
Test Create, Read, Update, and Delete of a Unit
39+
"""
40+
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
41+
42+
# Create a unit:
43+
create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc)
44+
with freeze_time(create_date):
45+
container_data = self._create_container(lib["id"], "unit", slug="u1", display_name="Test Unit")
46+
expected_data = {
47+
"container_key": "lct:CL-TEST:containers:unit:u1",
48+
"container_type": "unit",
49+
"display_name": "Test Unit",
50+
"last_published": None,
51+
"published_by": None,
52+
"last_draft_created": "2024-09-08T07:06:05Z",
53+
"last_draft_created_by": 'Bob',
54+
'has_unpublished_changes': True,
55+
'created': '2024-09-08T07:06:05Z',
56+
'modified': '2024-09-08T07:06:05Z',
57+
'collections': [],
58+
}
59+
60+
self.assertDictContainsEntries(container_data, expected_data)
61+
62+
# TODO: test that a regular user with read-only permissions on the library cannot create units

openedx/core/djangoapps/content_libraries/urls.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from rest_framework import routers
88

9-
from .rest_api import blocks, collections, libraries
9+
from .rest_api import blocks, collections, containers, libraries
1010

1111

1212
# Django application name.
@@ -38,6 +38,8 @@
3838
path('block_types/', libraries.LibraryBlockTypesView.as_view()),
3939
# Get the list of XBlocks in this library, or add a new one:
4040
path('blocks/', blocks.LibraryBlocksView.as_view()),
41+
# Add a new container (unit etc.) to this library:
42+
path('containers/', containers.LibraryContainersView.as_view()),
4143
# Publish (POST) or revert (DELETE) all pending changes to this library:
4244
path('commit/', libraries.LibraryCommitView.as_view()),
4345
# Get the list of users/groups who have permission to view/edit/administer this library:
@@ -70,6 +72,14 @@
7072
path('publish/', blocks.LibraryBlockPublishView.as_view()),
7173
# Future: discard changes for just this one block
7274
])),
75+
# Containers are Sections, Subsections, and Units
76+
path('containers/<usage_v2:container_key>/', include([
77+
# Get metadata about a specific container in this library, or delete the container:
78+
# path('', views.LibraryContainerView.as_view()),
79+
# Update collections for a given container
80+
# path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
81+
# path('publish/', views.LibraryContainerPublishView.as_view()),
82+
])),
7383
re_path(r'^lti/1.3/', include([
7484
path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'),
7585
path('launch/', libraries.LtiToolLaunchView.as_view(), name='lti-launch'),

0 commit comments

Comments
 (0)