diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 17bea80b3a96..cea92a0d2d02 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -152,6 +152,7 @@ class ContentLibraryMetadata: key = attr.ib(type=LibraryLocatorV2) title = attr.ib("") description = attr.ib("") + learning_package_id = attr.ib(default=None, type=int) num_blocks = attr.ib(0) version = attr.ib(0) type = attr.ib(default=COMPLEX) @@ -392,6 +393,7 @@ def get_library(library_key): return ContentLibraryMetadata( key=library_key, title=learning_package.title, + learning_package_id=learning_package.id, type=ref.type, description=ref.learning_package.description, num_blocks=num_blocks, diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 497eda81475b..42941143f111 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -245,3 +245,25 @@ class ContentLibraryBlockImportTaskCreateSerializer(serializers.Serializer): """ course_key = CourseKeyField() + + +class LibraryCollectionCreationSerializer(serializers.Serializer): + """ + Serializer to create a new library collection. + """ + + title = serializers.CharField() + description = serializers.CharField(allow_blank=True) + + +class LibraryCollectionMetadataSerializer(serializers.Serializer): + """ + Serializer for Library Collection Metadata. + """ + + # TODO Set this "id" with the LibraryCollectionKey + id = serializers.CharField(read_only=True) + # Rename collection.key to "slug" because "key" is a reserved prop name in React + slug = serializers.CharField(source="key", read_only=True) + title = serializers.CharField() + description = serializers.CharField(allow_blank=True) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 95b7309b3cd1..c8d433dadc7f 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -24,6 +24,7 @@ from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx.core.djangoapps.content_libraries.tests.base import ( ContentLibrariesRestApiTest, + URL_LIB_DETAIL, URL_BLOCK_METADATA_URL, URL_BLOCK_RENDER_VIEW, URL_BLOCK_GET_HANDLER_URL, @@ -1063,3 +1064,55 @@ def test_not_found_fails_correctly(self): self.assertEqual(response.json(), { 'detail': f"XBlock {valid_not_found_key} does not exist, or you don't have permission to view it.", }) + + +class LibraryCollectionsTestCase(ContentLibrariesRestApiTest): + + def test_create_collection(self): + """ + Test collection creation + + Tests with some non-ASCII chars in title, description. + """ + url = URL_LIB_DETAIL + 'collections/' + lib = self._create_library( + slug="téstlꜟط", title="A Tést Lꜟطrary", description="Just Téstꜟng", license_type=CC_4_BY, + ) + collection = self._api("post", url.format(lib_key=lib["id"]), { + 'title': 'A Tést Lꜟطrary Collection', + 'description': 'Just Téstꜟng', + }, 200) + expected_data = { + 'id': '1', + 'slug': 'a-test-lrary-collection', + 'title': 'A Tést Lꜟطrary Collection', + 'description': 'Just Téstꜟng' + } + self.assertDictContainsEntries(collection, expected_data) + + def test_create_collection_same_key(self): + """ + Test collection creation with same key + """ + url = URL_LIB_DETAIL + 'collections/' + lib = self._create_library( + slug="téstlꜟط", title="A Tést Lꜟطrary", description="Just Téstꜟng", license_type=CC_4_BY, + ) + + self._api("post", url.format(lib_key=lib["id"]), { + 'title': 'Test Collection', + 'description': 'Just a Test', + }, 200) + + for i in range(0, 100): + collection = self._api("post", url.format(lib_key=lib["id"]), { + 'title': 'Test Collection', + 'description': 'Just a Test', + }, 200) + expected_data = { + 'id': collection['id'], + 'slug': f'test-collection-{i + 1}', + 'title': 'Test Collection', + 'description': 'Just a Test' + } + self.assertDictContainsEntries(collection, expected_data) diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 6e450df63522..198e3a0a6c49 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -45,6 +45,8 @@ path('import_blocks/', include(import_blocks_router.urls)), # Paste contents of clipboard into library path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()), + # list of library collections / create a library collection + path('collections/', views.LibraryCollectionsRootView.as_view()), ])), path('blocks//', include([ # Get metadata about a specific XBlock in this library, or delete the block: diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index bde8142d3fcc..ce9427f70e96 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -72,6 +72,7 @@ from django.contrib.auth import authenticate, get_user_model, login from django.contrib.auth.models import Group from django.db.transaction import atomic, non_atomic_requests +from django.db.utils import IntegrityError from django.http import Http404, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404 from django.urls import reverse @@ -80,6 +81,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import TemplateResponseMixin, View +from django.utils.text import slugify from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin from pylti1p3.exception import LtiException, OIDCException @@ -88,6 +90,7 @@ from organizations.api import ensure_organization from organizations.exceptions import InvalidOrganizationException from organizations.models import Organization +from openedx_learning.api import authoring as authoring_api from rest_framework import status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.generics import GenericAPIView @@ -95,6 +98,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet +from openedx_events.content_authoring.data import LibraryCollectionData +from openedx_events.content_authoring.signals import LIBRARY_COLLECTION_CREATED from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.djangoapps.content_libraries.serializers import ( @@ -113,6 +118,8 @@ LibraryXBlockStaticFilesSerializer, ContentLibraryAddPermissionByEmailSerializer, LibraryPasteClipboardSerializer, + LibraryCollectionCreationSerializer, + LibraryCollectionMetadataSerializer, ) import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from openedx.core.lib.api.view_utils import view_auth_classes @@ -154,6 +161,10 @@ def wrapped_fn(*args, **kwargs): return wrapped_fn +# Library Views +# ============= + + class LibraryApiPaginationDocs: """ API docs for query params related to paginating ContentLibraryMetadata objects. @@ -829,6 +840,60 @@ def retrieve(self, request, lib_key_str, pk=None): return Response(ContentLibraryBlockImportTaskSerializer(import_task).data) +# Library Collections Views +# ============= + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryCollectionsRootView(GenericAPIView): + """ + Views to list and create library collections. + """ + + # TODO Implement list collections + + def post(self, request, lib_key_str): + """ + Create a new collection library. + """ + library_key = LibraryLocatorV2.from_string(lib_key_str) + api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) + serializer = LibraryCollectionCreationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + library = api.get_library(library_key) + title = serializer.validated_data['title'] + + key = slugify(title) + + attempt = 0 + result = None + + # It's possible that the key is not unique in the database + # So to avoid that, we add a correlative number in the key + while not result: + modified_key = key if attempt == 0 else key + '-' + str(attempt) + try: + result = authoring_api.create_collection( + learning_package_id=library.learning_package_id, + key=modified_key, + title=title, + description=serializer.validated_data['description'], + created_by=request.user.id, + ) + except IntegrityError: + attempt += 1 + + LIBRARY_COLLECTION_CREATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=result.id, + ) + ) + + return Response(LibraryCollectionMetadataSerializer(result).data) + + # LTI 1.3 Views # ============= diff --git a/requirements/constraints.txt b/requirements/constraints.txt index fd6dac56afff..d9a4cfa643c3 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -93,7 +93,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.11.2 +openedx-learning==0.11.4 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. openai<=0.28.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index f3108c6fdd8e..22d7758900af 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -811,7 +811,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -824,7 +824,7 @@ openedx-filters==1.9.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/collection-key # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 068b4796249f..e2f7d705995b 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1358,7 +1358,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1373,7 +1373,7 @@ openedx-filters==1.9.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/collection-key # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 1cc46ae3c44e..695bb2a0a2c1 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -970,7 +970,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -983,7 +983,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/collection-key # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index a5b510742ac7..5734e07bfb9b 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -117,9 +117,9 @@ olxcleaner openedx-atlas # CLI tool to manage translations openedx-calc # Library supporting mathematical calculations for Open edX openedx-django-require -openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) +openedx-events>=9.14.0 # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) -openedx-learning # Open edX Learning core (experimental) +git+https://github.com/open-craft/openedx-learning.git@jill/collection-key#egg=openedx-learning # Open edX Learning core (experimental) openedx-mongodbproxy openedx-django-wiki path diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 580745fbd3cc..3f4b809c6d7c 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1021,7 +1021,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1034,7 +1034,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning @ git+https://github.com/open-craft/openedx-learning.git@jill/collection-key # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt