Skip to content

Commit bce12f4

Browse files
feat: new REST API for units in content libraries
1 parent ce1f85e commit bce12f4

File tree

8 files changed

+174
-1
lines changed

8 files changed

+174
-1
lines changed

cms/envs/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,6 +1876,7 @@
18761876
"openedx_learning.apps.authoring.components",
18771877
"openedx_learning.apps.authoring.contents",
18781878
"openedx_learning.apps.authoring.publishing",
1879+
"openedx_learning.apps.authoring.units",
18791880
]
18801881

18811882

lms/envs/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3373,6 +3373,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
33733373
"openedx_learning.apps.authoring.components",
33743374
"openedx_learning.apps.authoring.contents",
33753375
"openedx_learning.apps.authoring.publishing",
3376+
"openedx_learning.apps.authoring.units",
33763377
]
33773378

33783379

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
from .containers import *
12
from .libraries import *
23
from .blocks import *
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
API for containers (Sections, Subsections, Units) in Content Libraries
3+
"""
4+
from __future__ import annotations
5+
from datetime import datetime
6+
from typing import Literal
7+
from uuid import uuid4
8+
9+
from django.utils.text import slugify
10+
from opaque_keys.edx.locator import (
11+
LibraryLocatorV2,
12+
LibraryContainerLocator,
13+
)
14+
15+
from openedx_learning.api import authoring as authoring_api
16+
from openedx_learning.api.authoring_models import (
17+
Component,
18+
ComponentVersion,
19+
LearningPackage,
20+
PublishableEntity,
21+
Unit,
22+
)
23+
24+
from ..constants import CONTAINER_UNIT_TYPE
25+
from ..models import ContentLibrary
26+
27+
# The public API is only the following symbols:
28+
__all__ = [
29+
"create_container",
30+
]
31+
32+
33+
def create_container(
34+
library_key: LibraryLocatorV2,
35+
container_type: Literal["unit"], # Python won't let us define the type using CONTAINER_UNIT_TYPE unfortunately
36+
slug: str | None,
37+
title: str,
38+
user_id: int | None,
39+
) -> tuple[LibraryContainerLocator, Unit]:
40+
"""
41+
Create a container (e.g. a Unit) in the specified content library.
42+
43+
It will initially be empty.
44+
"""
45+
assert isinstance(library_key, LibraryLocatorV2)
46+
assert container_type in [CONTAINER_UNIT_TYPE]
47+
content_library = ContentLibrary.objects.get_by_key(library_key)
48+
if slug is None:
49+
# Automatically generate a slug. Append a random suffix so it should be unique.
50+
slug = slugify(title, allow_unicode=True) + '-' + uuid4().hex[-6:]
51+
# Make sure the slug is valid by first creating a key for the new container:
52+
container_key = LibraryContainerLocator(lib_key=library_key, container_type=container_type, container_id=slug)
53+
# Then try creating the actual container:
54+
container, _initial_version = authoring_api.create_unit_and_version(
55+
content_library.learning_package_id,
56+
key=slug,
57+
title=title,
58+
created=datetime.now(),
59+
created_by=user_id,
60+
)
61+
return container_key, container

openedx/core/djangoapps/content_libraries/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
CC_4_BY_ND = 'CC:4.0:BY:ND'
1212
CC_4_BY_SA = 'CC:4.0:BY:SA'
1313

14+
# Container types
15+
CONTAINER_UNIT_TYPE = 'unit'
16+
1417
LICENSE_OPTIONS = (
1518
(ALL_RIGHTS_RESERVED, _('All Rights Reserved.')),
1619
(CC_4_BY, _('Creative Commons Attribution 4.0')),
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""
2+
REST API views for containers (sections, subsections, units) in content libraries
3+
"""
4+
from __future__ import annotations
5+
6+
from functools import wraps
7+
import logging
8+
9+
from django.conf import settings
10+
from django.contrib.auth import authenticate, get_user_model, login
11+
from django.contrib.auth.models import Group
12+
from django.core.exceptions import ObjectDoesNotExist
13+
from django.db.transaction import atomic, non_atomic_requests
14+
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, StreamingHttpResponse
15+
from django.shortcuts import get_object_or_404
16+
from django.urls import reverse
17+
from django.utils.decorators import method_decorator
18+
from django.utils.translation import gettext as _
19+
from django.views.decorators.clickjacking import xframe_options_exempt
20+
from django.views.decorators.csrf import csrf_exempt
21+
from django.views.generic.base import TemplateResponseMixin, View
22+
from drf_yasg.utils import swagger_auto_schema
23+
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
24+
from pylti1p3.exception import LtiException, OIDCException
25+
26+
import edx_api_doc_tools as apidocs
27+
from opaque_keys import InvalidKeyError
28+
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator
29+
from rest_framework import status
30+
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
31+
from rest_framework.generics import GenericAPIView
32+
from rest_framework.parsers import MultiPartParser
33+
from rest_framework.response import Response
34+
from rest_framework.views import APIView
35+
from rest_framework.viewsets import GenericViewSet
36+
37+
from openedx.core.djangoapps.content_libraries import api, permissions
38+
from openedx.core.lib.api.view_utils import view_auth_classes
39+
from openedx.core.djangoapps.xblock import api as xblock_api
40+
from openedx.core.types.http import RestRequest
41+
from . import serializers
42+
from .utils import convert_exceptions
43+
44+
User = get_user_model()
45+
log = logging.getLogger(__name__)
46+
47+
48+
@method_decorator(non_atomic_requests, name="dispatch")
49+
@view_auth_classes()
50+
class LibraryContainersView(GenericAPIView):
51+
"""
52+
Views to work with Containers in a specific content library.
53+
"""
54+
serializer_class = serializers.LibraryContainerMetadataSerializer
55+
56+
@convert_exceptions
57+
@swagger_auto_schema(
58+
request_body=serializers.LibraryContainerMetadataSerializer,
59+
responses={200: serializers.LibraryContainerMetadataSerializer}
60+
)
61+
def post(self, request, lib_key_str):
62+
"""
63+
Create a new Container in this content library
64+
"""
65+
library_key = LibraryLocatorV2.from_string(lib_key_str)
66+
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
67+
serializer = serializers.LibraryContainerMetadataSerializer(data=request.data)
68+
serializer.is_valid(raise_exception=True)
69+
70+
# Create a new regular top-level block:
71+
container_type = serializer.validated_data['container_type']
72+
_key, container = api.create_container(container_type)
73+
74+
return Response(serializers.LibraryContainerMetadataSerializer(container).data)

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,28 @@ class LibraryXBlockStaticFilesSerializer(serializers.Serializer):
230230
files = LibraryXBlockStaticFileSerializer(many=True)
231231

232232

233+
class LibraryContainerMetadataSerializer(serializers.Serializer):
234+
"""
235+
Serializer for Containers like Sections, Subsections, Units
236+
"""
237+
id = serializers.CharField(source="container_key", read_only=True)
238+
container_type = serializers.CharField(source="container_key.container_type")
239+
title = serializers.CharField(read_only=True)
240+
last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
241+
published_by = serializers.CharField(read_only=True)
242+
last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
243+
last_draft_created_by = serializers.CharField(read_only=True)
244+
has_unpublished_changes = serializers.BooleanField(read_only=True)
245+
created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
246+
modified = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
247+
tags_count = serializers.IntegerField(read_only=True)
248+
collections = CollectionMetadataSerializer(many=True, required=False, read_only=True)
249+
250+
# When creating a new container in a library, the slug becomes the ID part of
251+
# the definition key and usage key:
252+
slug = serializers.CharField(write_only=True)
253+
254+
233255
class ContentLibraryBlockImportTaskSerializer(serializers.ModelSerializer):
234256
"""
235257
Serializer for a Content Library block import task.

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)