feat: api to restore soft-deleted component [FC-0076] (#35993)

Adds API to handle restoring soft-deleted library blocks.
This commit is contained in:
Navin Karkera
2024-12-13 23:47:46 +05:30
committed by GitHub
parent f4d110c896
commit 971afe6095
8 changed files with 120 additions and 24 deletions

View File

@@ -306,7 +306,10 @@ def _collections_for_content_object(object_id: UsageKey | LearningContextKey) ->
If the object is in no collections, returns:
{
"collections": {},
"collections": {
"display_name": [],
"key": [],
},
}
"""

View File

@@ -185,3 +185,13 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase):
meilisearch_client.return_value.index.return_value.delete_document.assert_called_with(
"lborgalib_aproblemproblem1-ca3186e9"
)
# Restore the Library Block
library_api.restore_library_block(problem.usage_key)
meilisearch_client.return_value.index.return_value.update_documents.assert_any_call([doc_problem])
meilisearch_client.return_value.index.return_value.update_documents.assert_any_call(
[{'id': doc_problem['id'], 'collections': {'display_name': [], 'key': []}}]
)
meilisearch_client.return_value.index.return_value.update_documents.assert_any_call(
[{'id': doc_problem['id'], 'tags': {}}]
)

View File

@@ -1133,6 +1133,46 @@ def delete_library_block(usage_key, remove_from_parent=True):
)
def restore_library_block(usage_key):
"""
Restore the specified library block.
"""
component = get_component_from_usage_key(usage_key)
library_key = usage_key.context_key
affected_collections = authoring_api.get_entity_collections(component.learning_package_id, component.key)
# Set draft version back to the latest available component version id.
authoring_api.set_draft_version(component.pk, component.versioning.latest.pk)
LIBRARY_BLOCK_CREATED.send_event(
library_block=LibraryBlockData(
library_key=library_key,
usage_key=usage_key
)
)
# Add tags and collections back to index
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
content_object=ContentObjectChangedData(
object_id=str(usage_key),
changes=["collections", "tags"],
),
)
# For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
# collection indexing asynchronously.
#
# To restore the component in the collections
for collection in affected_collections:
LIBRARY_COLLECTION_UPDATED.send_event(
library_collection=LibraryCollectionData(
library_key=library_key,
collection_key=collection.key,
background=True,
)
)
def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticFile]:
"""
Given an XBlock in a content library, list all the static asset files

View File

@@ -591,6 +591,35 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
event_receiver.call_args_list[0].kwargs,
)
def test_restore_library_block(self):
api.update_library_collection_components(
self.lib1.library_key,
self.col1.key,
usage_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
],
)
event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(event_receiver)
api.restore_library_block(UsageKey.from_string(self.lib1_problem_block["id"]))
assert event_receiver.call_count == 1
self.assertDictContainsSubset(
{
"signal": LIBRARY_COLLECTION_UPDATED,
"sender": None,
"library_collection": LibraryCollectionData(
self.lib1.library_key,
collection_key=self.col1.key,
background=True,
),
},
event_receiver.call_args_list[0].kwargs,
)
def test_add_component_and_revert(self):
# Add component and publish
api.update_library_collection_components(

View File

@@ -57,6 +57,7 @@ urlpatterns = [
path('blocks/<str:usage_key_str>/', include([
# Get metadata about a specific XBlock in this library, or delete the block:
path('', views.LibraryBlockView.as_view()),
path('restore/', views.LibraryBlockRestore.as_view()),
# Update collections for a given component
path('collections/', views.LibraryBlockCollectionsView.as_view(), name='update-collections'),
# Get the LTI URL of a specific XBlock

View File

@@ -644,6 +644,22 @@ class LibraryBlockView(APIView):
return Response({})
@view_auth_classes()
class LibraryBlockRestore(APIView):
"""
View to restore soft-deleted library xblocks.
"""
@convert_exceptions
def post(self, request, usage_key_str) -> Response:
"""
Restores a soft-deleted library block that belongs to a Content Library
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
api.restore_library_block(key)
return Response(None, status=status.HTTP_204_NO_CONTENT)
@method_decorator(non_atomic_requests, name="dispatch")
@view_auth_classes()
class LibraryBlockCollectionsView(APIView):

View File

@@ -20,7 +20,6 @@ from openedx_events.content_authoring.signals import (
XBLOCK_DUPLICATED,
LIBRARY_BLOCK_CREATED,
LIBRARY_BLOCK_UPDATED,
LIBRARY_BLOCK_DELETED,
)
from .api import copy_object_tags
@@ -30,7 +29,6 @@ from .tasks import (
update_course_tags,
update_xblock_tags,
update_library_block_tags,
delete_library_block_tags,
)
from .toggles import CONTENT_TAGGING_AUTO
@@ -119,22 +117,6 @@ def auto_tag_library_block(**kwargs):
)
@receiver(LIBRARY_BLOCK_DELETED)
def delete_tag_library_block(**kwargs):
"""
Delete tags associated with a Library XBlock whenever the block is deleted.
"""
library_block_data = kwargs.get("library_block", None)
if not library_block_data or not isinstance(library_block_data, LibraryBlockData):
log.error("Received null or incorrect data for event")
return
try:
delete_library_block_tags(str(library_block_data.usage_key))
except Exception as err: # pylint: disable=broad-except
log.error(f"Failed to delete library block tags: {err}")
@receiver(XBLOCK_DUPLICATED)
def duplicate_tags(**kwargs):
"""

View File

@@ -14,7 +14,9 @@ from organizations.models import Organization
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangolib.testing.utils import skip_unless_cms
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from openedx.core.djangoapps.content_libraries.api import create_library, create_library_block, delete_library_block
from openedx.core.djangoapps.content_libraries.api import (
create_library, create_library_block, delete_library_block, restore_library_block
)
from .. import api
from ..models.base import TaxonomyOrg
@@ -267,7 +269,7 @@ class TestAutoTagging( # type: ignore[misc]
# Still no tags
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None)
def test_create_delete_library_block(self):
def test_create_delete_restore_library_block(self):
# Create library
library = create_library(
org=self.orgA,
@@ -287,11 +289,17 @@ class TestAutoTagging( # type: ignore[misc]
# Check if the tags are created in the Library Block with the user's preferred language
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, 'Português (Brasil)')
# Delete the XBlock
# Soft delete the XBlock
delete_library_block(library_block.usage_key)
# Check if the tags are deleted
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None)
# Check that the tags are not deleted
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, 'Português (Brasil)')
# Restore the XBlock
restore_library_block(library_block.usage_key)
# Check if the tags are still present in the Library Block with the user's preferred language
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, 'Português (Brasil)')
@override_waffle_flag(CONTENT_TAGGING_AUTO, active=False)
def test_waffle_disabled_create_delete_library_block(self):
@@ -319,3 +327,10 @@ class TestAutoTagging( # type: ignore[misc]
# Still no tags
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None)
# Restore the XBlock
with patch('crum.get_current_request', return_value=fake_request):
restore_library_block(library_block.usage_key)
# Still no tags
assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None)