Merge pull request #23233 from open-craft/blockstore-library-links

REST API for managing Content Library bundle links
This commit is contained in:
David Ormsbee
2020-04-30 11:32:48 -04:00
committed by GitHub
8 changed files with 380 additions and 14 deletions

View File

@@ -1,8 +1,39 @@
"""
Python API for content libraries.
Unless otherwise specified, all APIs in this file deal with the DRAFT version
of the content library.
Via 'views.py', most of these API methods are also exposed as a REST API.
The API methods in this file are focused on authoring and specific to content
libraries; they wouldn't necessarily apply or work in other learning contexts
such as courses, blogs, "pathways," etc.
** As this is an authoring-focused API, all API methods in this file deal with
the DRAFT version of the content library. **
Some of these methods will work and may be used from the LMS if needed (mostly
for test setup; other use is discouraged), but some of the implementation
details rely on Studio so other methods will raise errors if called from the
LMS. (The REST API is not available at all from the LMS.)
Any APIs that use/affect content libraries but are generic enough to work in
other learning contexts too are in the core XBlock python/REST API at
openedx.core.djangoapps.xblock.api/rest_api
For example, to render a content library XBlock as HTML, one can use the generic
render_block_view(block, view_name, user)
API in openedx.core.djangoapps.xblock.api (use it from Studio for the draft
version, from the LMS for published version).
There are one or two methods in this file that have some overlap with the core
XBlock API; for example, this content library API provides a get_library_block()
which returns metadata about an XBlock; it's in this API because it also returns
data about whether or not the XBlock has unpublished edits, which is an
authoring-only concern. Likewise, APIs for getting/setting an individual
XBlock's OLX directly seem more appropriate for small, reusable components in
content libraries and may not be appropriate for other learning contexts so they
are implemented here in the library API only. In the future, if we find a need
for these in most other learning contexts then those methods could be promoted
to the core XBlock API and made generic.
"""
from uuid import UUID
import logging
@@ -13,6 +44,7 @@ from django.core.exceptions import PermissionDenied
from django.core.validators import validate_unicode_slug
from django.db import IntegrityError
from lxml import etree
from opaque_keys.edx.keys import LearningContextKey
from opaque_keys.edx.locator import BundleDefinitionLocator, LibraryLocatorV2, LibraryUsageLocatorV2
from organizations.models import Organization
import six
@@ -34,6 +66,7 @@ from openedx.core.lib.blockstore_api import (
update_bundle,
delete_bundle,
write_draft_file,
set_draft_link,
commit_draft,
delete_draft,
)
@@ -69,7 +102,7 @@ class InvalidNameError(ValueError):
# Models:
@attr.s
class ContentLibraryMetadata(object):
class ContentLibraryMetadata:
"""
Class that represents the metadata about a content library.
"""
@@ -90,7 +123,7 @@ class ContentLibraryMetadata(object):
allow_public_read = attr.ib(False)
class AccessLevel(object):
class AccessLevel:
""" Enum defining library access levels/permissions """
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
@@ -99,7 +132,7 @@ class AccessLevel(object):
@attr.s
class ContentLibraryPermissionEntry(object):
class ContentLibraryPermissionEntry:
"""
A user or group granted permission to use a content library.
"""
@@ -109,7 +142,7 @@ class ContentLibraryPermissionEntry(object):
@attr.s
class LibraryXBlockMetadata(object):
class LibraryXBlockMetadata:
"""
Class that represents the metadata about an XBlock in a content library.
"""
@@ -120,7 +153,7 @@ class LibraryXBlockMetadata(object):
@attr.s
class LibraryXBlockStaticFile(object):
class LibraryXBlockStaticFile:
"""
Class that represents a static file in a content library, associated with
a particular XBlock.
@@ -135,7 +168,7 @@ class LibraryXBlockStaticFile(object):
@attr.s
class LibraryXBlockType(object):
class LibraryXBlockType:
"""
An XBlock type that can be added to a content library
"""
@@ -143,6 +176,33 @@ class LibraryXBlockType(object):
display_name = attr.ib("")
@attr.s
class LibraryBundleLink:
"""
A link from a content library blockstore bundle to another blockstore bundle
"""
# Bundle that is linked to
bundle_uuid = attr.ib(type=UUID)
# Link name (slug)
id = attr.ib("")
# What version of this bundle we are currently linking to.
version = attr.ib(0)
# What the latest version of the linked bundle is:
# (if latest_version > version), the link can be "updated" to the latest version.
latest_version = attr.ib(0)
# Opaque key: If the linked bundle is a library or other learning context whose opaque key we can deduce, then this
# is the key. If we don't know what type of blockstore bundle this link is pointing to, then this is blank.
opaque_key = attr.ib(type=LearningContextKey, default=None)
class AccessLevel:
""" Enum defining library access levels/permissions """
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
READ_LEVEL = ContentLibraryPermission.READ_LEVEL
NO_ACCESS = None
def list_libraries_for_user(user):
"""
Lists up to 50 content libraries that the user has permission to view.
@@ -643,6 +703,93 @@ def get_allowed_block_types(library_key): # pylint: disable=unused-argument
return info
def get_bundle_links(library_key):
"""
Get the list of bundles/libraries linked to this content library.
Returns LibraryBundleLink objects (defined above).
Because every content library is a blockstore bundle, it can have "links" to
other bundles, which may or may not be content libraries. This allows using
XBlocks (or perhaps even static assets etc.) from another bundle without
needing to duplicate/copy the data.
Links always point to a specific published version of the target bundle.
Links are identified by a slug-like ID, e.g. "link1"
"""
ref = ContentLibrary.objects.get_by_key(library_key)
links = blockstore_cache.get_bundle_draft_direct_links_cached(ref.bundle_uuid, DRAFT_NAME)
results = []
# To be able to quickly get the library ID from the bundle ID for links which point to other libraries, build a map:
bundle_uuids = set(link_data.bundle_uuid for link_data in links.values())
libraries_linked = {
lib.bundle_uuid: lib
for lib in ContentLibrary.objects.select_related('org').filter(bundle_uuid__in=bundle_uuids)
}
for link_name, link_data in links.items():
# Is this linked bundle a content library?
try:
opaque_key = libraries_linked[link_data.bundle_uuid].library_key
except KeyError:
opaque_key = None
# Append the link information:
results.append(LibraryBundleLink(
id=link_name,
bundle_uuid=link_data.bundle_uuid,
version=link_data.version,
latest_version=blockstore_cache.get_bundle_version_number(link_data.bundle_uuid),
opaque_key=opaque_key,
))
return results
def create_bundle_link(library_key, link_id, target_opaque_key, version=None):
"""
Create a new link to the resource with the specified opaque key.
For now, only LibraryLocatorV2 opaque keys are supported.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
# Make sure this link ID/name is not already in use:
links = blockstore_cache.get_bundle_draft_direct_links_cached(ref.bundle_uuid, DRAFT_NAME)
if link_id in links:
raise InvalidNameError("That link ID is already in use.")
# Determine the target:
if not isinstance(target_opaque_key, LibraryLocatorV2):
raise TypeError("For now, only LibraryLocatorV2 opaque keys are supported by create_bundle_link")
target_bundle_uuid = ContentLibrary.objects.get_by_key(target_opaque_key).bundle_uuid
if version is None:
version = get_bundle(target_bundle_uuid).latest_version
# Create the new link:
draft = get_or_create_bundle_draft(ref.bundle_uuid, DRAFT_NAME)
set_draft_link(draft.uuid, link_id, target_bundle_uuid, version)
# Clear the cache:
LibraryBundle(library_key, ref.bundle_uuid, draft_name=DRAFT_NAME).cache.clear()
def update_bundle_link(library_key, link_id, version=None, delete=False):
"""
Update a bundle's link to point to the specified version of its target
bundle. Use version=None to automatically point to the latest version.
Use delete=True to delete the link.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
draft = get_or_create_bundle_draft(ref.bundle_uuid, DRAFT_NAME)
if delete:
set_draft_link(draft.uuid, link_id, None, None)
else:
links = blockstore_cache.get_bundle_draft_direct_links_cached(ref.bundle_uuid, DRAFT_NAME)
try:
link = links[link_id]
except KeyError:
raise InvalidNameError("That link does not exist.")
if version is None:
version = get_bundle(link.bundle_uuid).latest_version
set_draft_link(draft.uuid, link_id, link.bundle_uuid, version)
# Clear the cache:
LibraryBundle(library_key, ref.bundle_uuid, draft_name=DRAFT_NAME).cache.clear()
def publish_changes(library_key):
"""
Publish all pending changes to the specified library.

View File

@@ -18,6 +18,7 @@ from openedx.core.djangoapps.xblock.runtime.olx_parsing import (
)
from openedx.core.djangolib.blockstore_cache import (
BundleCache,
get_bundle_direct_links_with_cache,
get_bundle_files_cached,
get_bundle_file_metadata_with_cache,
get_bundle_version_number,
@@ -299,6 +300,12 @@ class LibraryBundle(object):
has_unpublished_changes = True
break
if not has_unpublished_changes:
# Check if any links have changed:
old_links = set(get_bundle_direct_links_with_cache(self.bundle_uuid).items())
new_links = set(get_bundle_direct_links_with_cache(self.bundle_uuid, draft_name=self.draft_name).items())
has_unpublished_changes = new_links != old_links
published_file_paths = set(f.path for f in get_bundle_files_cached(self.bundle_uuid))
draft_file_paths = set(f.path for f in draft_files)
for file_path in published_file_paths:

View File

@@ -84,6 +84,32 @@ class LibraryXBlockTypeSerializer(serializers.Serializer):
display_name = serializers.CharField()
class LibraryBundleLinkSerializer(serializers.Serializer):
"""
Serializer for a link from a content library blockstore bundle to another
blockstore bundle.
"""
id = serializers.SlugField() # Link name
bundle_uuid = serializers.UUIDField(format='hex_verbose', read_only=True)
# What version of this bundle we are currently linking to.
# This is never NULL but can optionally be set to null when creating a new link, which means "use latest version."
version = serializers.IntegerField(allow_null=True)
# What the latest version of the linked bundle is:
# (if latest_version > version), the link can be "updated" to the latest version.
latest_version = serializers.IntegerField(read_only=True)
# Opaque key: If the linked bundle is a library or other learning context whose opaque key we can deduce, then this
# is the key. If we don't know what type of blockstore bundle this link is pointing to, then this is blank.
opaque_key = serializers.CharField()
class LibraryBundleLinkUpdateSerializer(serializers.Serializer):
"""
Serializer for updating an existing link in a content library blockstore
bundle.
"""
version = serializers.IntegerField(allow_null=True)
class LibraryXBlockCreationSerializer(serializers.Serializer):
"""
Serializer for adding a new XBlock to a content library

View File

@@ -3,12 +3,12 @@
Tests for Blockstore-based Content Libraries
"""
from contextlib import contextmanager
from io import BytesIO
import unittest
from django.conf import settings
from organizations.models import Organization
from rest_framework.test import APITestCase, APIClient
import six
from student.tests.factories import UserFactory
from openedx.core.djangolib.testing.utils import skip_unless_cms
@@ -20,6 +20,7 @@ URL_PREFIX = '/api/libraries/v2/'
URL_LIB_CREATE = URL_PREFIX
URL_LIB_DETAIL = URL_PREFIX + '{lib_key}/' # Get data about a library, update or delete library
URL_LIB_BLOCK_TYPES = URL_LIB_DETAIL + 'block_types/' # Get the list of XBlock types that can be added to this library
URL_LIB_LINKS = URL_LIB_DETAIL + 'links/' # Get the list of links in this library, or add a new one
URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library
URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one
URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library
@@ -67,7 +68,7 @@ class ContentLibrariesRestApiTest(APITestCase):
@classmethod
def setUpClass(cls):
super(ContentLibrariesRestApiTest, cls).setUpClass()
super().setUpClass()
cls.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
# Create a collection using Blockstore API directly only because there
# is not yet any Studio REST API for doing so:
@@ -79,7 +80,7 @@ class ContentLibrariesRestApiTest(APITestCase):
)
def setUp(self):
super(ContentLibrariesRestApiTest, self).setUp()
super().setUp()
self.clients_by_user = {}
self.client.login(username=self.user.username, password="edx")
@@ -143,6 +144,23 @@ class ContentLibrariesRestApiTest(APITestCase):
""" Delete an existing library """
return self._api('delete', URL_LIB_DETAIL.format(lib_key=lib_key), None, expect_response)
def _get_library_links(self, lib_key):
""" Get the links of the specified content library """
return self._api('get', URL_LIB_LINKS.format(lib_key=lib_key), None, expect_response=200)
def _link_to_library(self, lib_key, link_id, other_library_key, version=None):
"""
Modify the library identified by lib_key to create a named link to
other_library_key. This allows you to use XBlocks from other_library in
lib. Optionally specify a version to link to.
"""
data = {
"id": link_id,
"opaque_key": other_library_key,
"version": version,
}
return self._api('post', URL_LIB_LINKS.format(lib_key=lib_key), data, expect_response=200)
def _commit_library_changes(self, lib_key, expect_response=200):
""" Commit changes to an existing library """
return self._api('post', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response)
@@ -221,8 +239,8 @@ class ContentLibrariesRestApiTest(APITestCase):
content should be a binary string.
"""
assert isinstance(content, six.binary_type)
file_handle = six.BytesIO(content)
assert isinstance(content, bytes)
file_handle = BytesIO(content)
url = URL_LIB_BLOCK_ASSET_FILE.format(block_key=block_key, file_name=file_name)
response = self.client.put(url, data={"content": file_handle})
self.assertEqual(

View File

@@ -355,3 +355,91 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
self._delete_library_block(block3_key)
self._commit_library_changes(lib_id)
self._revert_library_changes(lib_id) # This is a no-op after the commit, but should still have 200 response
def test_library_blocks_with_links(self):
"""
Test that libraries can link to XBlocks in other content libraries
"""
# Create a problem bank:
bank_lib = self._create_library(slug="problem_bank", title="Problem Bank")
bank_lib_id = bank_lib["id"]
# Add problem1 to the problem bank:
p1 = self._add_block_to_library(bank_lib_id, "problem", "problem1")
self._set_library_block_olx(p1["id"], """
<problem><multiplechoiceresponse>
<p>What is an even number?</p>
<choicegroup type="MultipleChoice">
<choice correct="false">3</choice>
<choice correct="true">2</choice>
</choicegroup>
</multiplechoiceresponse></problem>
""")
# Commit the changes, creating version 1:
self._commit_library_changes(bank_lib_id)
# Now update problem 1 and create a new problem 2:
self._set_library_block_olx(p1["id"], """
<problem><multiplechoiceresponse>
<p>What is an odd number?</p>
<choicegroup type="MultipleChoice">
<choice correct="true">3</choice>
<choice correct="false">2</choice>
</choicegroup>
</multiplechoiceresponse></problem>
""")
p2 = self._add_block_to_library(bank_lib_id, "problem", "problem2")
self._set_library_block_olx(p2["id"], """
<problem><multiplechoiceresponse>
<p>What holds this XBlock?</p>
<choicegroup type="MultipleChoice">
<choice correct="false">A course</choice>
<choice correct="true">A problem bank</choice>
</choicegroup>
</multiplechoiceresponse></problem>
""")
# Commit the changes, creating version 2:
self._commit_library_changes(bank_lib_id)
# At this point, bank_lib contains two problems and has two versions.
# In version 1, problem1 is "What is an event number", and in version 2 it's "What is an odd number".
# Problem2 exists only in version 2 and asks "What holds this XBlock?"
lib = self._create_library(slug="links_test_lib", title="Link Test Library")
lib_id = lib["id"]
# Link to the problem bank:
self._link_to_library(lib_id, "problem_bank", bank_lib_id)
self._link_to_library(lib_id, "problem_bank_v1", bank_lib_id, version=1)
# Add a 'unit' XBlock to the library:
unit_block = self._add_block_to_library(lib_id, "unit", "unit1")
self._set_library_block_olx(unit_block["id"], """
<unit>
<!-- version 2 link to "What is an odd number?" -->
<xblock-include source="problem_bank" definition="problem/problem1"/>
<!-- version 1 link to "What is an even number?" -->
<xblock-include source="problem_bank_v1" definition="problem/problem1" usage="p1v1" />
<!-- link to "What holds this XBlock?" -->
<xblock-include source="problem_bank" definition="problem/problem2"/>
</unit>
""")
# The unit can see and render its children:
fragment = self._render_block_view(unit_block["id"], "student_view")
self.assertIn("What is an odd number?", fragment["content"])
self.assertIn("What is an even number?", fragment["content"])
self.assertIn("What holds this XBlock?", fragment["content"])
# Also check the API for retrieving links:
links_created = self._get_library_links(lib_id)
links_created.sort(key=lambda link: link["id"])
self.assertEqual(len(links_created), 2)
self.assertEqual(links_created[0]["id"], "problem_bank")
self.assertEqual(links_created[0]["bundle_uuid"], bank_lib["bundle_uuid"])
self.assertEqual(links_created[0]["version"], 2)
self.assertEqual(links_created[0]["latest_version"], 2)
self.assertEqual(links_created[0]["opaque_key"], bank_lib_id)
self.assertEqual(links_created[1]["id"], "problem_bank_v1")
self.assertEqual(links_created[1]["bundle_uuid"], bank_lib["bundle_uuid"])
self.assertEqual(links_created[1]["version"], 1)
self.assertEqual(links_created[1]["latest_version"], 2)
self.assertEqual(links_created[1]["opaque_key"], bank_lib_id)

View File

@@ -20,6 +20,10 @@ urlpatterns = [
url(r'^$', views.LibraryDetailsView.as_view()),
# Get the list of XBlock types that can be added to this library
url(r'^block_types/$', views.LibraryBlockTypesView.as_view()),
# Get the list of Blockstore Bundle Links for this library, or add a new one:
url(r'^links/$', views.LibraryLinksView.as_view()),
# Update or delete a link:
url(r'^links/(?P<link_id>[^/]+)/$', views.LibraryLinkDetailView.as_view()),
# Get the list of XBlocks in this library, or add a new one:
url(r'^blocks/$', views.LibraryBlocksView.as_view()),
# Commit (POST) or revert (DELETE) all pending changes to this library:

View File

@@ -24,6 +24,8 @@ from openedx.core.djangoapps.content_libraries.serializers import (
LibraryXBlockCreationSerializer,
LibraryXBlockMetadataSerializer,
LibraryXBlockTypeSerializer,
LibraryBundleLinkSerializer,
LibraryBundleLinkUpdateSerializer,
LibraryXBlockOlxSerializer,
LibraryXBlockStaticFileSerializer,
LibraryXBlockStaticFilesSerializer,
@@ -241,6 +243,80 @@ class LibraryBlockTypesView(APIView):
return Response(LibraryXBlockTypeSerializer(result, many=True).data)
@view_auth_classes()
class LibraryLinksView(APIView):
"""
View to get the list of bundles/libraries linked to this content library.
Because every content library is a blockstore bundle, it can have "links" to
other bundles, which may or may not be content libraries. This allows using
XBlocks (or perhaps even static assets etc.) from another bundle without
needing to duplicate/copy the data.
Links always point to a specific published version of the target bundle.
Links are identified by a slug-like ID, e.g. "link1"
"""
@convert_exceptions
def get(self, request, lib_key_str):
"""
Get the list of bundles that this library links to, if any
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
result = api.get_bundle_links(key)
return Response(LibraryBundleLinkSerializer(result, many=True).data)
@convert_exceptions
def post(self, request, lib_key_str):
"""
Create a new link in this library.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
serializer = LibraryBundleLinkSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
target_key = LibraryLocatorV2.from_string(serializer.validated_data['opaque_key'])
api.create_bundle_link(
library_key=key,
link_id=serializer.validated_data['id'],
target_opaque_key=target_key,
version=serializer.validated_data['version'], # a number, or None for "use latest version"
)
return Response({})
@view_auth_classes()
class LibraryLinkDetailView(APIView):
"""
View to update/delete an existing library link
"""
@convert_exceptions
def patch(self, request, lib_key_str, link_id):
"""
Update the specified link to point to a different version of its
target bundle.
Pass e.g. {"version": 40} or pass {"version": None} to update to the
latest published version.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
serializer = LibraryBundleLinkUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
api.update_bundle_link(key, link_id, version=serializer.validated_data['version'])
return Response({})
@convert_exceptions
def delete(self, request, lib_key_str, link_id): # pylint: disable=unused-argument
"""
Delete a link from this library.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
api.update_bundle_link(key, link_id, delete=True)
return Response({})
@view_auth_classes()
class LibraryCommitView(APIView):
"""

View File

@@ -264,7 +264,7 @@ def get_bundle_version_links(bundle_uuid, version_number):
Get a dictionary of the links in the specified bundle version
"""
if version_number == 0:
return []
return {}
version_url = api_url('bundle_versions', str(bundle_uuid) + ',' + str(version_number))
version_info = api_request('get', version_url)
return {