fix: call blockstore APIs in atomic transactions. [BD-14] (#30456)

* fix: call blockstore APIs in atomic transactions.

To ensure database integry when using the blockstore APIs, the Content
Library views which invoke blockstore API methods are wrapped in
database transactions.

* fix: assert create_library is called inside an atomic transaction block
This commit is contained in:
Jillian Vogel
2022-06-15 01:40:08 +09:30
committed by GitHub
parent a10661ddbc
commit 5688ad0ba7
4 changed files with 87 additions and 38 deletions

View File

@@ -426,6 +426,10 @@ def create_library(
"""
assert isinstance(collection_uuid, UUID)
assert isinstance(org, Organization)
assert not transaction.get_autocommit(), (
"Call within a django.db.transaction.atomic block so that all created objects are rolled back on error."
)
validate_unicode_slug(slug)
# First, create the blockstore bundle:
bundle = create_bundle(
@@ -436,20 +440,16 @@ def create_library(
)
# Now create the library reference in our database:
try:
# Atomic transaction required because if this fails,
# we need to delete the bundle in the exception handler.
with transaction.atomic():
ref = ContentLibrary.objects.create(
org=org,
slug=slug,
type=library_type,
bundle_uuid=bundle.uuid,
allow_public_learning=allow_public_learning,
allow_public_read=allow_public_read,
license=library_license,
)
ref = ContentLibrary.objects.create(
org=org,
slug=slug,
type=library_type,
bundle_uuid=bundle.uuid,
allow_public_learning=allow_public_learning,
allow_public_read=allow_public_read,
license=library_license,
)
except IntegrityError:
delete_bundle(bundle.uuid)
raise LibraryAlreadyExists(slug) # lint-amnesty, pylint: disable=raise-missing-from
CONTENT_LIBRARY_CREATED.send(sender=None, library_key=ref.library_key)
return ContentLibraryMetadata(

View File

@@ -30,6 +30,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import (
)
from openedx.core.djangoapps.content_libraries.constants import VIDEO, COMPLEX, PROBLEM, CC_4_BY, ALL_RIGHTS_RESERVED
from openedx.core.djangolib.blockstore_cache import cache
from openedx.core.lib import blockstore_api
from common.djangoapps.student.tests.factories import UserFactory
@@ -189,10 +190,22 @@ class ContentLibrariesTestMixin:
You can't create a library with the same slug as an existing library,
or an invalid slug.
"""
assert 0 == len(blockstore_api.get_bundles(text_search='some-slug'))
self._create_library(slug="some-slug", title="Existing Library")
self._create_library(slug="some-slug", title="Duplicate Library", expect_response=400)
assert 1 == len(blockstore_api.get_bundles(text_search='some-slug'))
self._create_library(slug="Invalid Slug!", title="Library with Bad Slug", expect_response=400)
# Try to create a library+bundle with a duplicate slug
response = self._create_library(slug="some-slug", title="Duplicate Library", expect_response=400)
assert response == {
'slug': 'A library with that ID already exists.',
}
# The second bundle created with that slug is removed when the transaction rolls back.
assert 1 == len(blockstore_api.get_bundles(text_search='some-slug'))
response = self._create_library(slug="Invalid Slug!", title="Library with Bad Slug", expect_response=400)
assert response == {
'slug': ['Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens.'],
}
@ddt.data(True, False)
@patch("openedx.core.djangoapps.content_libraries.views.LibraryApiPagination.page_size", new=2)

View File

@@ -5,7 +5,7 @@ import json
from gettext import GNUTranslations
from completion.test_utils import CompletionWaffleTestMixin
from django.db import connections
from django.db import connections, transaction
from django.test import LiveServerTestCase, TestCase
from django.utils.text import slugify
from organizations.models import Organization
@@ -51,17 +51,18 @@ class ContentLibraryContentTestMixin:
short_name="CL-TEST",
)
_, slug = self.id().rsplit('.', 1)
self.library = library_api.create_library(
collection_uuid=self.collection.uuid,
library_type=COMPLEX,
org=self.organization,
slug=slugify(slug),
title=(f"{slug} Test Lib"),
description="",
allow_public_learning=True,
allow_public_read=False,
library_license=ALL_RIGHTS_RESERVED,
)
with transaction.atomic():
self.library = library_api.create_library(
collection_uuid=self.collection.uuid,
library_type=COMPLEX,
org=self.organization,
slug=slugify(slug),
title=(f"{slug} Test Lib"),
description="",
allow_public_learning=True,
allow_public_read=False,
library_license=ALL_RIGHTS_RESERVED,
)
class ContentLibraryRuntimeTestMixin(ContentLibraryContentTestMixin):
@@ -83,17 +84,18 @@ class ContentLibraryRuntimeTestMixin(ContentLibraryContentTestMixin):
library_api.create_library_block_child(unit_block_key, "problem", "p1")
library_api.publish_changes(self.library.key)
# Now do the same in a different library:
library2 = library_api.create_library(
collection_uuid=self.collection.uuid,
org=self.organization,
slug="idolx",
title=("Identical OLX Test Lib 2"),
description="",
library_type=COMPLEX,
allow_public_learning=True,
allow_public_read=False,
library_license=CC_4_BY,
)
with transaction.atomic():
library2 = library_api.create_library(
collection_uuid=self.collection.uuid,
org=self.organization,
slug="idolx",
title=("Identical OLX Test Lib 2"),
description="",
library_type=COMPLEX,
allow_public_learning=True,
allow_public_read=False,
library_license=CC_4_BY,
)
unit_block2_key = library_api.create_library_block(library2.key, "unit", "u1").usage_key
library_api.create_library_block_child(unit_block2_key, "problem", "p1")
library_api.publish_changes(library2.key)

View File

@@ -18,6 +18,7 @@ from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.contrib.auth.models import Group
from django.db.transaction import atomic
from django.http import Http404
from django.http import HttpResponseBadRequest
from django.http import JsonResponse
@@ -142,6 +143,7 @@ class LibraryRootView(APIView):
Views to list, search for, and create content libraries.
"""
@atomic
@apidocs.schema(
parameters=[
*LibraryApiPagination.apidoc_params,
@@ -184,6 +186,7 @@ class LibraryRootView(APIView):
return paginator.get_paginated_response(serializer.data)
return Response(serializer.data)
@atomic
def post(self, request):
"""
Create a new content library.
@@ -223,6 +226,7 @@ class LibraryDetailsView(APIView):
"""
Views to work with a specific content library
"""
@atomic
@convert_exceptions
def get(self, request, lib_key_str):
"""
@@ -233,6 +237,7 @@ class LibraryDetailsView(APIView):
result = api.get_library(key)
return Response(ContentLibraryMetadataSerializer(result).data)
@atomic
@convert_exceptions
def patch(self, request, lib_key_str):
"""
@@ -255,6 +260,7 @@ class LibraryDetailsView(APIView):
result = api.get_library(key)
return Response(ContentLibraryMetadataSerializer(result).data)
@atomic
@convert_exceptions
def delete(self, request, lib_key_str): # pylint: disable=unused-argument
"""
@@ -275,6 +281,7 @@ class LibraryTeamView(APIView):
Note also the 'allow_public_' settings which can be edited by PATCHing the
library itself (LibraryDetailsView.patch).
"""
@atomic
@convert_exceptions
def post(self, request, lib_key_str):
"""
@@ -302,6 +309,7 @@ class LibraryTeamView(APIView):
grant = api.get_library_user_permissions(key, user)
return Response(ContentLibraryPermissionSerializer(grant).data)
@atomic
@convert_exceptions
def get(self, request, lib_key_str):
"""
@@ -320,6 +328,7 @@ class LibraryTeamUserView(APIView):
View to add/remove/edit an individual user's permissions for a content
library.
"""
@atomic
@convert_exceptions
def put(self, request, lib_key_str, username):
"""
@@ -338,6 +347,7 @@ class LibraryTeamUserView(APIView):
grant = api.get_library_user_permissions(key, user)
return Response(ContentLibraryPermissionSerializer(grant).data)
@atomic
@convert_exceptions
def get(self, request, lib_key_str, username):
"""
@@ -351,6 +361,7 @@ class LibraryTeamUserView(APIView):
raise NotFound
return Response(ContentLibraryPermissionSerializer(grant).data)
@atomic
@convert_exceptions
def delete(self, request, lib_key_str, username):
"""
@@ -372,6 +383,7 @@ class LibraryTeamGroupView(APIView):
"""
View to add/remove/edit a group's permissions for a content library.
"""
@atomic
@convert_exceptions
def put(self, request, lib_key_str, group_name):
"""
@@ -386,6 +398,7 @@ class LibraryTeamGroupView(APIView):
api.set_library_group_permissions(key, group, access_level=serializer.validated_data["access_level"])
return Response({})
@atomic
@convert_exceptions
def delete(self, request, lib_key_str, username):
"""
@@ -404,6 +417,7 @@ class LibraryBlockTypesView(APIView):
"""
View to get the list of XBlock types that can be added to this library
"""
@atomic
@convert_exceptions
def get(self, request, lib_key_str):
"""
@@ -428,6 +442,7 @@ class LibraryLinksView(APIView):
Links always point to a specific published version of the target bundle.
Links are identified by a slug-like ID, e.g. "link1"
"""
@atomic
@convert_exceptions
def get(self, request, lib_key_str):
"""
@@ -438,6 +453,7 @@ class LibraryLinksView(APIView):
result = api.get_bundle_links(key)
return Response(LibraryBundleLinkSerializer(result, many=True).data)
@atomic
@convert_exceptions
def post(self, request, lib_key_str):
"""
@@ -462,6 +478,7 @@ class LibraryLinkDetailView(APIView):
"""
View to update/delete an existing library link
"""
@atomic
@convert_exceptions
def patch(self, request, lib_key_str, link_id):
"""
@@ -478,6 +495,7 @@ class LibraryLinkDetailView(APIView):
api.update_bundle_link(key, link_id, version=serializer.validated_data['version'])
return Response({})
@atomic
@convert_exceptions
def delete(self, request, lib_key_str, link_id): # pylint: disable=unused-argument
"""
@@ -494,6 +512,7 @@ class LibraryCommitView(APIView):
"""
Commit/publish or revert all of the draft changes made to the library.
"""
@atomic
@convert_exceptions
def post(self, request, lib_key_str):
"""
@@ -505,6 +524,7 @@ class LibraryCommitView(APIView):
api.publish_changes(key)
return Response({})
@atomic
@convert_exceptions
def delete(self, request, lib_key_str): # pylint: disable=unused-argument
"""
@@ -522,6 +542,7 @@ class LibraryBlocksView(APIView):
"""
Views to work with XBlocks in a specific content library.
"""
@atomic
@apidocs.schema(
parameters=[
*LibraryApiPagination.apidoc_params,
@@ -560,6 +581,7 @@ class LibraryBlocksView(APIView):
return Response(LibraryXBlockMetadataSerializer(result, many=True).data)
@atomic
@convert_exceptions
def post(self, request, lib_key_str):
"""
@@ -592,6 +614,7 @@ class LibraryBlockView(APIView):
"""
Views to work with an existing XBlock in a content library.
"""
@atomic
@convert_exceptions
def get(self, request, usage_key_str):
"""
@@ -602,6 +625,7 @@ class LibraryBlockView(APIView):
result = api.get_library_block(key)
return Response(LibraryXBlockMetadataSerializer(result).data)
@atomic
@convert_exceptions
def delete(self, request, usage_key_str): # pylint: disable=unused-argument
"""
@@ -628,6 +652,7 @@ class LibraryBlockLtiUrlView(APIView):
Returns 404 in case the block not found by the given key.
"""
@atomic
@convert_exceptions
def get(self, request, usage_key_str):
"""
@@ -647,6 +672,7 @@ class LibraryBlockOlxView(APIView):
"""
Views to work with an existing XBlock's OLX
"""
@atomic
@convert_exceptions
def get(self, request, usage_key_str):
"""
@@ -657,6 +683,7 @@ class LibraryBlockOlxView(APIView):
xml_str = api.get_library_block_olx(key)
return Response(LibraryXBlockOlxSerializer({"olx": xml_str}).data)
@atomic
@convert_exceptions
def post(self, request, usage_key_str):
"""
@@ -682,6 +709,7 @@ class LibraryBlockAssetListView(APIView):
"""
Views to list an existing XBlock's static asset files
"""
@atomic
@convert_exceptions
def get(self, request, usage_key_str):
"""
@@ -700,6 +728,7 @@ class LibraryBlockAssetView(APIView):
"""
parser_classes = (MultiPartParser, )
@atomic
@convert_exceptions
def get(self, request, usage_key_str, file_path):
"""
@@ -713,6 +742,7 @@ class LibraryBlockAssetView(APIView):
return Response(LibraryXBlockStaticFileSerializer(f).data)
raise NotFound
@atomic
@convert_exceptions
def put(self, request, usage_key_str, file_path):
"""
@@ -735,6 +765,7 @@ class LibraryBlockAssetView(APIView):
raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from
return Response(LibraryXBlockStaticFileSerializer(result).data)
@atomic
@convert_exceptions
def delete(self, request, usage_key_str, file_path):
"""
@@ -757,6 +788,7 @@ class LibraryImportTaskViewSet(ViewSet):
Import blocks from Courseware through modulestore.
"""
@atomic
@convert_exceptions
def list(self, request, lib_key_str):
"""
@@ -775,6 +807,7 @@ class LibraryImportTaskViewSet(ViewSet):
paginator.paginate_queryset(result, request)
)
@atomic
@convert_exceptions
def create(self, request, lib_key_str):
"""
@@ -795,6 +828,7 @@ class LibraryImportTaskViewSet(ViewSet):
import_task = api.import_blocks_create_task(library_key, course_key)
return Response(ContentLibraryBlockImportTaskSerializer(import_task).data)
@atomic
@convert_exceptions
def retrieve(self, request, lib_key_str, pk=None):
"""