Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Library Collections REST endpoints [FC-0062] #35321

Merged
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ed30ce2
feat: Add Library Collections REST endpoints
yusuf-musleh Aug 14, 2024
3d64ea4
test: Add tests for Collections REST APIs
yusuf-musleh Aug 22, 2024
931f688
chore: Add missing __init__ files
yusuf-musleh Aug 23, 2024
b2b38cb
feat: Verify collection belongs to library
yusuf-musleh Aug 23, 2024
a16b398
feat: Add events emitting for Collections
yusuf-musleh Aug 26, 2024
be39d03
chore: fix pylint errors
yusuf-musleh Aug 26, 2024
22fa791
refactor: Use convert_exceptions + update tests
yusuf-musleh Aug 29, 2024
0bf0f78
refactor: Relocate convert_exceptions, remove utils
yusuf-musleh Aug 30, 2024
45cf886
refactor: Remove Collection handlers skeleton code
yusuf-musleh Aug 30, 2024
366a7e9
refactor: Move Collections views/tests
yusuf-musleh Aug 30, 2024
504aa4f
feat: Add REST endpoints to update Components in a Collections (temp)…
pomegranited Sep 3, 2024
a2da207
refactor: pull view functionality into api
pomegranited Sep 3, 2024
aa3c1e3
temp: use temporary openedx-learning branch
pomegranited Sep 4, 2024
6ae83ba
refactor: use collection.key as ID in search records
pomegranited Sep 4, 2024
abb1eb1
refactor: use Collection.key as identifier in content_libraries
pomegranited Sep 4, 2024
0eb52cf
test: adds tests for events raised by collections api
pomegranited Sep 4, 2024
6f39767
temp: use temporary openedx-events branch
pomegranited Sep 4, 2024
007f80a
fix: catch IntegrityError in create_library_collection
pomegranited Sep 6, 2024
197733b
Merge remote-tracking branch 'origin/master' into yusuf-musleh/collec…
pomegranited Sep 6, 2024
d312ff3
chore: updates openedx-events==9.14.0
pomegranited Sep 10, 2024
715527c
Merge branch 'master' into yusuf-musleh/collections-crud-rest-api
pomegranited Sep 10, 2024
d5aeff8
chore: updates openedx-learning==0.11.4
pomegranited Sep 11, 2024
e6b469d
refactor: Update collections crud rest api (#683)
ChrisChV Sep 11, 2024
1738447
fix: assert collection doc have unique id
rpenido Sep 11, 2024
b645044
Merge branch 'master' into yusuf-musleh/collections-crud-rest-api
ChrisChV Sep 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/hooks/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,15 @@ Content Authoring Events
* - `CONTENT_OBJECT_TAGS_CHANGED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L207>`_
- org.openedx.content_authoring.content.object.tags.changed.v1
- 2024-03-31

* - `LIBRARY_COLLECTION_CREATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L219>`_
- org.openedx.content_authoring.content.library.collection.created.v1
- 2024-08-23

* - `LIBRARY_COLLECTION_UPDATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L230>`_
- org.openedx.content_authoring.content.library.collection.updated.v1
- 2024-08-23

* - `LIBRARY_COLLECTION_DELETED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L241>`_
- org.openedx.content_authoring.content.library.collection.deleted.v1
- 2024-08-23
yusuf-musleh marked this conversation as resolved.
Show resolved Hide resolved
12 changes: 9 additions & 3 deletions openedx/core/djangoapps/content_libraries/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -323,13 +324,14 @@ 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
]
return libraries


def require_permission_for_library_key(library_key, user, permission):
def require_permission_for_library_key(library_key, user, permission) -> ContentLibrary:
yusuf-musleh marked this conversation as resolved.
Show resolved Hide resolved
"""
Given any of the content library permission strings defined in
openedx.core.djangoapps.content_libraries.permissions,
Expand All @@ -339,10 +341,12 @@ def require_permission_for_library_key(library_key, user, permission):
Raises django.core.exceptions.PermissionDenied if the user doesn't have
permission.
"""
library_obj = ContentLibrary.objects.get_by_key(library_key)
library_obj = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
if not user.has_perm(permission, obj=library_obj):
raise PermissionDenied

return library_obj


def get_library(library_key):
"""
Expand Down Expand Up @@ -408,6 +412,7 @@ def get_library(library_key):
license=ref.license,
created=learning_package.created,
updated=learning_package.updated,
learning_package=learning_package
)


Expand Down Expand Up @@ -479,6 +484,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
)


Expand Down
21 changes: 21 additions & 0 deletions openedx/core/djangoapps/content_libraries/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
pomegranited marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
"""
Tests Library Collections REST API views
"""

from __future__ import annotations

from openedx_learning.api.authoring_models import Collection
from openedx_learning.api.authoring import create_collection
from opaque_keys.edx.locator import LibraryLocatorV2

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")

# Create Content Library Collections
self.col1 = create_collection(
learning_package_id=self.lib1.learning_package.id,
title="Collection 1",
created_by=self.user.id,
description="Description for Collection 1",
)

self.col2 = create_collection(
learning_package_id=self.lib1.learning_package.id,
title="Collection 2",
created_by=self.user.id,
description="Description for Collection 2",
)
self.col3 = create_collection(
learning_package_id=self.lib2.learning_package.id,
title="Collection 3",
created_by=self.user.id,
description="Description for Collection 3",
)

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_get_invalid_library_collection(self):
"""
Test retrieving a an invalid Content Library Collection or one that does not exist
"""
# Fetch collection that belongs to a different library, it should fail
resp = self.client.get(
URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_id=self.col3.id)
)

assert resp.status_code == 404

# Fetch collection with invalid ID provided, it should fail
resp = self.client.get(
URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_id=123)
)

assert resp.status_code == 404

# Fetch collection with invalid library_key provided, it should fail
resp = self.client.get(
URL_LIB_COLLECTION.format(lib_key=123, collection_id=123)
)
assert resp.status_code == 404

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_list_invalid_library_collections(self):
"""
Test listing invalid Content Library Collections
"""
non_existing_key = LibraryLocatorV2.from_string("lib:DoesNotExist:NE1")
resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=non_existing_key))

assert resp.status_code == 404

# List collections with invalid library_key provided, it should fail
resp = resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=123))
assert resp.status_code == 404

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_create_invalid_library_collection(self):
"""
Test creating an invalid Content Library Collection
"""
post_data_missing_title = {
"description": "Description for Collection 4",
}
resp = self.client.post(
URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data_missing_title, format="json"
)

assert resp.status_code == 400

post_data_missing_desc = {
"title": "Collection 4",
}
resp = self.client.post(
URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data_missing_desc, format="json"
)

assert resp.status_code == 400

# Create collection with invalid library_key provided, it should fail
resp = self.client.post(
URL_LIB_COLLECTIONS.format(lib_key=123),
{**post_data_missing_title, **post_data_missing_desc},
format="json"
)
assert resp.status_code == 404

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_update_invalid_library_collection(self):
"""
Test updating an invalid Content Library Collection or one that does not exist
"""
patch_data = {
"title": "Collection 3 Updated",
}
# Update collection that belongs to a different library, it should fail
resp = self.client.patch(
URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_id=self.col3.id),
patch_data,
format="json"
)

assert resp.status_code == 404

# Update collection with invalid ID provided, it should fail
resp = self.client.patch(
URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_id=123),
patch_data,
format="json"
)

assert resp.status_code == 404

# Update collection with invalid library_key provided, it should fail
resp = self.client.patch(
URL_LIB_COLLECTION.format(lib_key=123, collection_id=self.col3.id),
patch_data,
format="json"
)
assert resp.status_code == 404

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
Loading
Loading