From 72d060cbeb1a02c740658ac086e05383e110b877 Mon Sep 17 00:00:00 2001 From: Nathan Sprenkle Date: Thu, 22 Aug 2024 12:34:30 -0400 Subject: [PATCH 1/6] feat: update XBlock to 5.1.0 (#35325) --- requirements/constraints.txt | 4 ---- requirements/edx/base.txt | 3 +-- requirements/edx/development.txt | 3 +-- requirements/edx/doc.txt | 3 +-- requirements/edx/testing.txt | 3 +-- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index aa45d9944d5a..146d8605101a 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -142,7 +142,3 @@ django-storages<1.14.4 # We are pinning this until after all the smaller migrations get handled and then we can migrate this all at once. # Ticket to unpin: https://github.com/edx/edx-arch-experiments/issues/760 social-auth-app-django<=5.4.1 - -# Xblock==5.0.0 changed how entrypoints were loaded, breaking a workaround for overriding blocks. -# See ticket: https://github.com/openedx/XBlock/issues/777 -xblock[django]==4.0.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 873446da1353..48b31cad0356 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -1258,9 +1258,8 @@ webob==1.8.8 # xblock wrapt==1.16.0 # via -r requirements/edx/paver.txt -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # acid-xblock # crowdsourcehinter-xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4951b16608b6..a4825209b60d 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -2231,9 +2231,8 @@ wrapt==1.16.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # astroid -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # acid-xblock diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 8c9c7bbbb379..d4bc6c14d37d 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1549,9 +1549,8 @@ webob==1.8.8 # xblock wrapt==1.16.0 # via -r requirements/edx/base.txt -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # acid-xblock # crowdsourcehinter-xblock diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 87c98537d3a7..6527b45b56bf 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1646,9 +1646,8 @@ wrapt==1.16.0 # via # -r requirements/edx/base.txt # astroid -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # acid-xblock # crowdsourcehinter-xblock From 6071992281437ae0eaa57c66755a74290ab2953f Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 22 Aug 2024 14:56:51 -0400 Subject: [PATCH 2/6] feat: lint this file (#35348) I'm about to make a bunch of changes to this file, and before I do I'm saving it and letting the linter reformatted to our current code style standards, so that code reviewers won't have to read a mix of lint and code changes. FIXES: APER-3554 --- openedx/core/djangoapps/user_api/views.py | 41 ++++++++++++----------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index b4fcc68db649..d52493556a19 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -1,6 +1,5 @@ """HTTP end-points for the User API. """ - from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.http import HttpResponse from django.utils.decorators import method_decorator @@ -16,21 +15,22 @@ from rest_framework.views import APIView from openedx.core.djangoapps.django_comment_common.models import Role -from openedx.core.lib.api.view_utils import require_post_params from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones, update_email_opt_in from openedx.core.djangoapps.user_api.serializers import ( CountryTimeZoneSerializer, UserPreferenceSerializer, - UserSerializer + UserSerializer, ) from openedx.core.lib.api.permissions import ApiKeyHeaderPermission +from openedx.core.lib.api.view_utils import require_post_params class UserViewSet(viewsets.ReadOnlyModelViewSet): """ DRF class for interacting with the User ORM object """ + permission_classes = (ApiKeyHeaderPermission,) queryset = User.objects.all().prefetch_related("preferences").select_related("profile") serializer_class = UserSerializer @@ -42,6 +42,7 @@ class ForumRoleUsersListView(generics.ListAPIView): """ Forum roles are represented by a list of user dicts """ + permission_classes = (ApiKeyHeaderPermission,) serializer_class = UserSerializer paginate_by = 10 @@ -51,10 +52,10 @@ def get_queryset(self): """ Return a list of users with the specified role/course pair """ - name = self.kwargs['name'] - course_id_string = self.request.query_params.get('course_id') + name = self.kwargs["name"] + course_id_string = self.request.query_params.get("course_id") if not course_id_string: - raise ParseError('course_id must be specified') + raise ParseError("course_id must be specified") course_id = CourseKey.from_string(course_id_string) role = Role.objects.get_or_create(course_id=course_id, name=name)[0] users = role.users.prefetch_related("preferences").select_related("profile").all() @@ -65,6 +66,7 @@ class UserPreferenceViewSet(viewsets.ReadOnlyModelViewSet): """ DRF class for interacting with the UserPreference ORM """ + permission_classes = (ApiKeyHeaderPermission,) queryset = UserPreference.objects.all() filter_backends = (DjangoFilterBackend,) @@ -78,26 +80,30 @@ class PreferenceUsersListView(generics.ListAPIView): """ DRF class for listing a user's preferences """ + permission_classes = (ApiKeyHeaderPermission,) serializer_class = UserSerializer paginate_by = 10 paginate_by_param = "page_size" def get_queryset(self): - return User.objects.filter( - preferences__key=self.kwargs["pref_key"] - ).prefetch_related("preferences").select_related("profile") + return ( + User.objects.filter(preferences__key=self.kwargs["pref_key"]) + .prefetch_related("preferences") + .select_related("profile") + ) class UpdateEmailOptInPreference(APIView): - """View for updating the email opt in preference. """ + """View for updating the email opt in preference.""" + authentication_classes = (SessionAuthenticationAllowInactiveUser,) permission_classes = (IsAuthenticated,) @method_decorator(require_post_params(["course_id", "email_opt_in"])) @method_decorator(ensure_csrf_cookie) def post(self, request): - """ Post function for updating the email opt in preference. + """Post function for updating the email opt in preference. Allows the modification or creation of the email opt in preference at an organizational level. @@ -111,17 +117,13 @@ def post(self, request): assume False. """ - course_id = request.data['course_id'] + course_id = request.data["course_id"] try: org = locator.CourseLocator.from_string(course_id).org except InvalidKeyError: - return HttpResponse( - status=400, - content=f"No course '{course_id}' found", - content_type="text/plain" - ) + return HttpResponse(status=400, content=f"No course '{course_id}' found", content_type="text/plain") # Only check for true. All other values are False. - email_opt_in = request.data['email_opt_in'].lower() == 'true' + email_opt_in = request.data["email_opt_in"].lower() == "true" update_email_opt_in(request.user, org, email_opt_in) return HttpResponse(status=status.HTTP_200_OK) @@ -152,9 +154,10 @@ class CountryTimeZoneListView(generics.ListAPIView): * time_zone: The name of the time zone. * description: The display version of the time zone """ + serializer_class = CountryTimeZoneSerializer paginator = None def get_queryset(self): - country_code = self.request.GET.get('country_code', None) + country_code = self.request.GET.get("country_code", None) return get_country_time_zones(country_code) From 36a3b0ba8178aa479464b9eccfb9f9b0e5e0240e Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:49:21 -0400 Subject: [PATCH 3/6] Revert "fix: update course discussion config before course load (#35219)" (#35349) This reverts commit 5c0942481ce292a90f86259bd223d66e7ceffe9f. --- cms/djangoapps/contentstore/views/course.py | 5 ----- cms/djangoapps/contentstore/views/tests/test_course_index.py | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 4647e4fdcca7..9f6cfb7c430e 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -58,7 +58,6 @@ from common.djangoapps.util.string_utils import _has_non_ascii_characters from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements -from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json @@ -303,10 +302,6 @@ def course_handler(request, course_key_string=None): else: return HttpResponseBadRequest() elif request.method == 'GET': # assume html - # Update course discussion settings, sometimes the course discussion settings are not updated - # when the course is created, so we need to update them here. - course_key = CourseKey.from_string(course_key_string) - update_discussions_settings_from_course(course_key) if course_key_string is None: return redirect(reverse('home')) else: diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index 30e02214a1a8..c3dcfe5305b7 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -717,8 +717,8 @@ def test_number_of_calls_to_db(self): """ Test to check number of queries made to mysql and mongo """ - with self.assertNumQueries(32, table_ignorelist=WAFFLE_TABLES): - with check_mongo_calls(5): + with self.assertNumQueries(29, table_ignorelist=WAFFLE_TABLES): + with check_mongo_calls(3): self.client.get_html(reverse_course_url('course_handler', self.course.id)) From 5fbcc794cfb49d30e9f4eca6536234817b57b7f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 23 Aug 2024 02:03:31 -0300 Subject: [PATCH 4/6] feat: add collections app from openedx-learning (#35312) --- cms/envs/common.py | 1 + lms/envs/common.py | 1 + requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 7 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index be837c518981..45a8e97f3e51 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1880,6 +1880,7 @@ 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", diff --git a/lms/envs/common.py b/lms/envs/common.py index 18d07afd7161..04a1753838ed 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3394,6 +3394,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 146d8605101a..74263b5f7141 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.10.1 +openedx-learning==0.11.1 # 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 48b31cad0356..27de7847f284 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -823,7 +823,7 @@ openedx-filters==1.9.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index a4825209b60d..0bdc8144ee71 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1372,7 +1372,7 @@ openedx-filters==1.9.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index d4bc6c14d37d..91b30d81df6d 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -982,7 +982,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 6527b45b56bf..2092c9354834 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1033,7 +1033,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From f58bb6759b751b3130e20927c71bc6a8812af47d Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Wed, 14 Aug 2024 07:36:15 +0300 Subject: [PATCH 5/6] feat: Add Library Collections REST endpoints --- .../core/djangoapps/content_libraries/api.py | 6 +- .../collections/rest_api/v1/views.py | 110 ++++++++++++++++++ .../content_libraries/serializers.py | 21 ++++ .../core/djangoapps/content_libraries/urls.py | 6 + 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 17bea80b3a96..42bc6d4879d2 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -86,7 +86,7 @@ LIBRARY_BLOCK_UPDATED, ) from openedx_learning.api import authoring as authoring_api -from openedx_learning.api.authoring_models import Component, MediaType +from openedx_learning.api.authoring_models import Component, MediaType, LearningPackage from organizations.models import Organization from xblock.core import XBlock from xblock.exceptions import XBlockNotFoundError @@ -150,6 +150,7 @@ class ContentLibraryMetadata: Class that represents the metadata about a content library. """ key = attr.ib(type=LibraryLocatorV2) + learning_package = attr.ib(type=LearningPackage) title = attr.ib("") description = attr.ib("") num_blocks = attr.ib(0) @@ -323,6 +324,7 @@ def get_metadata(queryset, text_search=None): has_unpublished_changes=False, has_unpublished_deletes=False, license=lib.license, + learning_package=lib.learning_package, ) for lib in queryset ] @@ -408,6 +410,7 @@ def get_library(library_key): license=ref.license, created=learning_package.created, updated=learning_package.updated, + learning_package=learning_package ) @@ -479,6 +482,7 @@ def create_library( allow_public_learning=ref.allow_public_learning, allow_public_read=ref.allow_public_read, license=library_license, + learning_package=ref.learning_package ) diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py new file mode 100644 index 000000000000..9fefa29e6170 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py @@ -0,0 +1,110 @@ +""" +Collections API Views +""" + +from __future__ import annotations + +from django.http import Http404 + +# from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED + +from opaque_keys.edx.locator import LibraryLocatorV2 + +from openedx.core.djangoapps.content_libraries import api, permissions +from openedx.core.djangoapps.content_libraries.serializers import ( + ContentLibraryCollectionSerializer, + ContentLibraryCollectionCreateOrUpdateSerializer, +) + +from openedx_learning.api.authoring_models import Collection +from openedx_learning.api import authoring as authoring_api + + +class LibraryCollectionsView(ModelViewSet): + """ + Views to get, create and update Library Collections. + """ + + serializer_class = ContentLibraryCollectionSerializer + + def retrieve(self, request, lib_key_str, pk=None): + """ + Retrieve the Content Library Collection + """ + try: + collection = authoring_api.get_collection(pk) + except Collection.DoesNotExist as exc: + raise Http404 from exc + + # Check if user has permissions to view this collection by checking if + # user has permission to view the Content Library it belongs to + library_key = LibraryLocatorV2.from_string(lib_key_str) + api.require_permission_for_library_key(library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + serializer = self.get_serializer(collection) + return Response(serializer.data) + + def list(self, request, lib_key_str): + """ + List Collections that belong to Content Library + """ + # Check if user has permissions to view collections by checking if user + # has permission to view the Content Library they belong to + library_key = LibraryLocatorV2.from_string(lib_key_str) + api.require_permission_for_library_key(library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + content_library = api.get_library(library_key) + collections = authoring_api.get_learning_package_collections(content_library.learning_package.id) + serializer = self.get_serializer(collections, many=True) + return Response(serializer.data) + + def create(self, request, lib_key_str): + """ + Create a Collection that belongs to a Content Library + """ + # Check if user has permissions to create a collection in the Content Library + # by checking if user has permission to edit the Content 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) + create_serializer = ContentLibraryCollectionCreateOrUpdateSerializer(data=request.data) + create_serializer.is_valid(raise_exception=True) + content_library = api.get_library(library_key) + collection = authoring_api.create_collection( + content_library.learning_package.id, + create_serializer.validated_data["title"], + request.user.id, + create_serializer.validated_data["description"] + ) + serializer = self.get_serializer(collection) + return Response(serializer.data) + + def partial_update(self, request, lib_key_str, pk=None): + """ + Update a Collection that belongs to a Content Library + """ + # Check if user has permissions to update a collection in the Content Library + # by checking if user has permission to edit the Content 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) + + try: + collection = authoring_api.get_collection(pk) + except Collection.DoesNotExist as exc: + raise Http404 from exc + + update_serializer = ContentLibraryCollectionCreateOrUpdateSerializer( + collection, data=request.data, partial=True + ) + update_serializer.is_valid(raise_exception=True) + updated_collection = authoring_api.update_collection(pk, **update_serializer.validated_data) + serializer = self.get_serializer(updated_collection) + return Response(serializer.data) + + def destroy(self, request, lib_key_str, pk=None): + """ + Deletes a Collection that belongs to a Content Library + + Note: (currently not allowed) + """ + return Response(None, status=HTTP_405_METHOD_NOT_ALLOWED) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 497eda81475b..7c49d4af3c2b 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -5,6 +5,8 @@ from django.core.validators import validate_unicode_slug from rest_framework import serializers + +from openedx_learning.api.authoring_models import Collection from openedx.core.djangoapps.content_libraries.constants import ( LIBRARY_TYPES, COMPLEX, @@ -245,3 +247,22 @@ class ContentLibraryBlockImportTaskCreateSerializer(serializers.Serializer): """ course_key = CourseKeyField() + + +class ContentLibraryCollectionSerializer(serializers.ModelSerializer): + """ + Serializer for a Content Library Collection + """ + + class Meta: + model = Collection + fields = '__all__' + + +class ContentLibraryCollectionCreateOrUpdateSerializer(serializers.Serializer): + """ + Serializer for add/update a Collection in a Content Library + """ + + title = serializers.CharField() + description = serializers.CharField() diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 6e450df63522..5521ad05bf63 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -7,6 +7,7 @@ from rest_framework import routers from . import views +from .collections.rest_api.v1 import views as collection_views # Django application name. @@ -18,6 +19,9 @@ import_blocks_router = routers.DefaultRouter() import_blocks_router.register(r'tasks', views.LibraryImportTaskViewSet, basename='import-block-task') +library_collections_router = routers.DefaultRouter() +library_collections_router.register(r'collections', collection_views.LibraryCollectionsView, basename="library-collections") + # These URLs are only used in Studio. The LMS already provides all the # API endpoints needed to serve XBlocks from content libraries using the # standard XBlock REST API (see openedx.core.django_apps.xblock.rest_api.urls) @@ -45,6 +49,8 @@ path('import_blocks/', include(import_blocks_router.urls)), # Paste contents of clipboard into library path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()), + # Library Collections + path('', include(library_collections_router.urls)), ])), path('blocks//', include([ # Get metadata about a specific XBlock in this library, or delete the block: From f4900071c3f9b0668390bc7bbaf361a4282aa5de Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Thu, 22 Aug 2024 08:33:45 +0300 Subject: [PATCH 6/6] test: Add tests for Collections REST APIs --- .../collections/rest_api/v1/tests/__init__.py | 0 .../rest_api/v1/tests/test_views.py | 184 ++++++++++++++++++ .../core/djangoapps/content_libraries/urls.py | 4 +- 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/__init__.py create mode 100644 openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/__init__.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py new file mode 100644 index 000000000000..3f37ad3a1c08 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py @@ -0,0 +1,184 @@ +""" +Tests Library Collections REST API views +""" + +from __future__ import annotations + +from openedx_learning.api.authoring_models import Collection + +from openedx.core.djangolib.testing.utils import skip_unless_cms +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangoapps.content_libraries.models import ContentLibrary +from common.djangoapps.student.tests.factories import UserFactory + +URL_PREFIX = '/api/libraries/v2/{lib_key}/' +URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/' +URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_id}/' + + +@skip_unless_cms # Content Library Collections REST API is only available in Studio +class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest): + """ + Tests for Content Library Collection REST API Views + """ + + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-col-1", "Test Library 1") + self._create_library("test-lib-col-2", "Test Library 2") + self.lib1 = ContentLibrary.objects.get(slug="test-lib-col-1") + self.lib2 = ContentLibrary.objects.get(slug="test-lib-col-2") + + print("self.lib1", self.lib1) + print("self.lib2", self.lib2) + + # Create Content Library Collections + self.col1 = Collection.objects.create( + learning_package_id=self.lib1.learning_package.id, + title="Collection 1", + description="Description for Collection 1", + created_by=self.user, + ) + self.col2 = Collection.objects.create( + learning_package_id=self.lib1.learning_package.id, + title="Collection 2", + description="Description for Collection 2", + created_by=self.user, + ) + self.col3 = Collection.objects.create( + learning_package_id=self.lib2.learning_package.id, + title="Collection 3", + description="Description for Collection 3", + created_by=self.user, + ) + + def test_get_library_collection(self): + """ + Test retrieving a Content Library Collection + """ + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_id=self.col3.id) + ) + + # Check that correct Content Library Collection data retrieved + expected_collection = { + "title": "Collection 3", + "description": "Description for Collection 3", + } + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, expected_collection) + + # Check that a random user without permissions cannot access Content Library Collection + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_id=self.col3.id) + ) + assert resp.status_code == 403 + + def test_list_library_collections(self): + """ + Test listing Content Library Collections + """ + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key)) + + # Check that the correct collections are listed + assert resp.status_code == 200 + assert len(resp.data) == 2 + expected_collections = [ + {"title": "Collection 1", "description": "Description for Collection 1"}, + {"title": "Collection 2", "description": "Description for Collection 2"}, + ] + for collection, expected in zip(resp.data, expected_collections): + self.assertDictContainsEntries(collection, expected) + + # Check that a random user without permissions cannot access Content Library Collections + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key)) + assert resp.status_code == 403 + + def test_create_library_collection(self): + """ + Test creating a Content Library Collection + """ + post_data = { + "title": "Collection 4", + "description": "Description for Collection 4", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + + # Check that the new Content Library Collection is returned in response and created in DB + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, post_data) + + created_collection = Collection.objects.get(id=resp.data["id"]) + self.assertIsNotNone(created_collection) + + # Check that user with read only access cannot create new Content Library Collection + reader = UserFactory.create(username="Reader", email="reader@example.com") + self._add_user_by_email(self.lib1.library_key, reader.email, access_level="read") + + with self.as_user(reader): + post_data = { + "title": "Collection 5", + "description": "Description for Collection 5", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + + assert resp.status_code == 403 + + def test_update_library_collection(self): + """ + Test updating a Content Library Collection + """ + patch_data = { + "title": "Collection 3 Updated", + } + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_id=self.col3.id), + patch_data, + format="json" + ) + + # Check that updated Content Library Collection is returned in response and updated in DB + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, patch_data) + + created_collection = Collection.objects.get(id=resp.data["id"]) + self.assertIsNotNone(created_collection) + self.assertEqual(created_collection.title, patch_data["title"]) + + # Check that user with read only access cannot update a Content Library Collection + reader = UserFactory.create(username="Reader", email="reader@example.com") + self._add_user_by_email(self.lib1.library_key, reader.email, access_level="read") + + with self.as_user(reader): + patch_data = { + "title": "Collection 3 should not update", + } + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_id=self.col3.id), + patch_data, + format="json" + ) + + assert resp.status_code == 403 + + def test_delete_library_collection(self): + """ + Test deleting a Content Library Collection + + Note: Currently not implemented and should return a 405 + """ + resp = self.client.delete( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_id=self.col3.id) + ) + + assert resp.status_code == 405 diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 5521ad05bf63..a0c5a9f8cd61 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -20,7 +20,9 @@ import_blocks_router.register(r'tasks', views.LibraryImportTaskViewSet, basename='import-block-task') library_collections_router = routers.DefaultRouter() -library_collections_router.register(r'collections', collection_views.LibraryCollectionsView, basename="library-collections") +library_collections_router.register( + r'collections', collection_views.LibraryCollectionsView, basename="library-collections" +) # These URLs are only used in Studio. The LMS already provides all the # API endpoints needed to serve XBlocks from content libraries using the