From 81b9453462c4cb867ef8752e5c785fd923855cd8 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 27 Feb 2020 18:55:01 -0800 Subject: [PATCH] API to get/update a content library's blockstore bundle links This adds some simple new python+REST APIs that can be used to create, read, update, and delete "links" from a content library to other content libraries. One can use these links to import content (XBlocks) into a library without copying the content. Note that this feature was already fully supported by Blockstore and the XBlock runtime; it's just that to use it prior to this required one to use the (lower-level) Blockstore REST API directly to create the links. Now there is a somewhat higher-level API built in to Studio, using "content library" abstractions instead of Blockstore primitives. --- .../core/djangoapps/content_libraries/api.py | 163 +++++++++++++++++- .../content_libraries/library_bundle.py | 7 + .../content_libraries/serializers.py | 26 +++ .../content_libraries/tests/base.py | 28 ++- .../tests/test_content_libraries.py | 88 ++++++++++ .../core/djangoapps/content_libraries/urls.py | 4 + .../djangoapps/content_libraries/views.py | 76 ++++++++ openedx/core/lib/blockstore_api/methods.py | 2 +- 8 files changed, 380 insertions(+), 14 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 9dc6570894..0366a87d46 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -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. diff --git a/openedx/core/djangoapps/content_libraries/library_bundle.py b/openedx/core/djangoapps/content_libraries/library_bundle.py index 954113a08c..27570a2ff8 100644 --- a/openedx/core/djangoapps/content_libraries/library_bundle.py +++ b/openedx/core/djangoapps/content_libraries/library_bundle.py @@ -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: diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 998df00881..e13f57c083 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -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 diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 63d8a7fa8e..efc530b824 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -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 @@ -66,7 +67,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: @@ -78,7 +79,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") @@ -142,6 +143,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) @@ -220,8 +238,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( 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 59ad24819c..afa54fd129 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -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"], """ + +

What is an even number?

+ + 3 + 2 + +
+ """) + # 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"], """ + +

What is an odd number?

+ + 3 + 2 + +
+ """) + p2 = self._add_block_to_library(bank_lib_id, "problem", "problem2") + self._set_library_block_olx(p2["id"], """ + +

What holds this XBlock?

+ + A course + A problem bank + +
+ """) + # 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"], """ + + + + + + + + + """) + + # 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) diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 2c08f87086..74019affb1 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -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[^/]+)/$', 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: diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index 6decb85803..7a1a41e565 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -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): """ diff --git a/openedx/core/lib/blockstore_api/methods.py b/openedx/core/lib/blockstore_api/methods.py index 7a3fa49ecf..a9735aace2 100644 --- a/openedx/core/lib/blockstore_api/methods.py +++ b/openedx/core/lib/blockstore_api/methods.py @@ -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 {