Skip to content

Commit dc4144e

Browse files
authored
feat: add restore container API, delete index when deleting container (#36464)
1 parent de75c37 commit dc4144e

10 files changed

Lines changed: 148 additions & 15 deletions

File tree

openedx/core/djangoapps/content/search/api.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
from meilisearch import Client as MeilisearchClient
1818
from meilisearch.errors import MeilisearchApiError, MeilisearchError
1919
from meilisearch.models.task import TaskInfo
20-
from opaque_keys import OpaqueKey
21-
from opaque_keys.edx.keys import UsageKey
20+
from opaque_keys.edx.keys import UsageKey, OpaqueKey
2221
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryCollectionLocator
2322
from openedx_learning.api import authoring as authoring_api
2423
from common.djangoapps.student.roles import GlobalStaff
@@ -613,14 +612,14 @@ def add_with_children(block):
613612
_update_index_docs(docs)
614613

615614

616-
def delete_index_doc(usage_key: UsageKey) -> None:
615+
def delete_index_doc(key: OpaqueKey) -> None:
617616
"""
618617
Deletes the document for the given XBlock from the search index
619618
620619
Args:
621-
usage_key (UsageKey): The usage key of the XBlock to be removed from the index
620+
key (OpaqueKey): The opaque key of the XBlock/Container to be removed from the index
622621
"""
623-
doc = searchable_doc_for_key(usage_key)
622+
doc = searchable_doc_for_key(key)
624623
_delete_index_doc(doc[Fields.id])
625624

626625

openedx/core/djangoapps/content/search/documents.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88

99
from django.core.exceptions import ObjectDoesNotExist
1010
from django.utils.text import slugify
11-
from opaque_keys import OpaqueKey
12-
from opaque_keys.edx.keys import LearningContextKey, UsageKey
11+
from opaque_keys.edx.keys import LearningContextKey, UsageKey, OpaqueKey
1312
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
1413
from openedx_learning.api import authoring as authoring_api
1514
from openedx_learning.api.authoring_models import Collection

openedx/core/djangoapps/content/search/handlers.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
)
4747
from .tasks import (
4848
delete_library_block_index_doc,
49+
delete_library_container_index_doc,
4950
delete_xblock_index_doc,
5051
update_content_library_index_docs,
5152
update_library_collection_index_doc,
@@ -238,7 +239,6 @@ def content_object_associations_changed_handler(**kwargs) -> None:
238239

239240

240241
@receiver(LIBRARY_CONTAINER_CREATED)
241-
@receiver(LIBRARY_CONTAINER_DELETED)
242242
@receiver(LIBRARY_CONTAINER_UPDATED)
243243
@only_if_meilisearch_enabled
244244
def library_container_updated_handler(**kwargs) -> None:
@@ -263,3 +263,19 @@ def library_container_updated_handler(**kwargs) -> None:
263263
str(library_container.library_key),
264264
library_container.container_key,
265265
])
266+
267+
268+
@receiver(LIBRARY_CONTAINER_DELETED)
269+
@only_if_meilisearch_enabled
270+
def library_container_deleted(**kwargs) -> None:
271+
"""
272+
Delete the index for the content library container
273+
"""
274+
library_container = kwargs.get("library_container", None)
275+
if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover
276+
log.error("Received null or incorrect data for event")
277+
return
278+
279+
# Update content library index synchronously to make sure that search index is updated before
280+
# the frontend invalidates/refetches results. This is only a single document update so is very fast.
281+
delete_library_container_index_doc.apply(args=[str(library_container.container_key)])

openedx/core/djangoapps/content/search/tasks.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
from edx_django_utils.monitoring import set_code_owner_attribute
1212
from meilisearch.errors import MeilisearchError
1313
from opaque_keys.edx.keys import UsageKey
14-
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
14+
from opaque_keys.edx.locator import (
15+
LibraryContainerLocator,
16+
LibraryLocatorV2,
17+
LibraryUsageLocatorV2,
18+
)
1519

1620
from . import api
1721

@@ -124,3 +128,16 @@ def update_library_container_index_doc(library_key_str: str, container_key_str:
124128
log.info("Updating content index documents for container %s in library%s", container_key, library_key)
125129

126130
api.upsert_library_container_index_doc(container_key)
131+
132+
133+
@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError))
134+
@set_code_owner_attribute
135+
def delete_library_container_index_doc(container_key_str: str) -> None:
136+
"""
137+
Celery task to delete the content index document for a library block
138+
"""
139+
container_key = LibraryContainerLocator.from_string(container_key_str)
140+
141+
log.info("Deleting content index document for library block with id: %s", container_key)
142+
143+
api.delete_index_doc(container_key)

openedx/core/djangoapps/content/search/tests/test_api.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,17 @@ def test_delete_collection(self, mock_meilisearch):
867867
doc_problem_without_collection,
868868
])
869869

870+
@override_settings(MEILISEARCH_ENABLED=True)
871+
def test_delete_index_container(self, mock_meilisearch):
872+
"""
873+
Test delete a container index.
874+
"""
875+
library_api.delete_container(self.unit.container_key)
876+
877+
mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with(
878+
self.unit_dict["id"],
879+
)
880+
870881
@override_settings(MEILISEARCH_ENABLED=True)
871882
def test_index_library_container_metadata(self, mock_meilisearch):
872883
"""

openedx/core/djangoapps/content_libraries/api/containers.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"library_container_locator",
4545
"update_container",
4646
"delete_container",
47+
"restore_container",
4748
"update_container_children",
4849
"get_containers_contains_component",
4950
]
@@ -121,7 +122,7 @@ def library_container_locator(
121122
)
122123

123124

124-
def _get_container(container_key: LibraryContainerLocator) -> Container:
125+
def _get_container(container_key: LibraryContainerLocator, isDeleted=False) -> Container:
125126
"""
126127
Internal method to fetch the Container object from its LibraryContainerLocator
127128
@@ -135,7 +136,7 @@ def _get_container(container_key: LibraryContainerLocator) -> Container:
135136
learning_package.id,
136137
key=container_key.container_id,
137138
)
138-
if container and container.versioning.draft:
139+
if container and (isDeleted or container.versioning.draft):
139140
return container
140141
raise ContentLibraryContainerNotFound
141142

@@ -234,10 +235,7 @@ def delete_container(
234235
235236
No-op if container doesn't exist or has already been soft-deleted.
236237
"""
237-
try:
238-
container = _get_container(container_key)
239-
except ContentLibraryContainerNotFound:
240-
return
238+
container = _get_container(container_key)
241239

242240
authoring_api.soft_delete_draft(container.pk)
243241

@@ -251,6 +249,22 @@ def delete_container(
251249
# TODO: trigger a LIBRARY_COLLECTION_UPDATED for each collection the container was in
252250

253251

252+
def restore_container(container_key: LibraryContainerLocator) -> None:
253+
"""
254+
Restore the specified library container.
255+
"""
256+
container = _get_container(container_key, isDeleted=True)
257+
258+
authoring_api.set_draft_version(container.pk, container.versioning.latest.pk)
259+
260+
LIBRARY_CONTAINER_CREATED.send_event(
261+
library_container=LibraryContainerData(
262+
library_key=container_key.library_key,
263+
container_key=str(container_key),
264+
)
265+
)
266+
267+
254268
def get_container_children(
255269
container_key: LibraryContainerLocator,
256270
published=False,

openedx/core/djangoapps/content_libraries/rest_api/containers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,3 +273,23 @@ def patch(self, request, container_key: LibraryContainerLocator):
273273
container_key,
274274
action=authoring_api.ChildrenEntitiesAction.REPLACE,
275275
)
276+
277+
278+
@method_decorator(non_atomic_requests, name="dispatch")
279+
@view_auth_classes()
280+
class LibraryContainerRestore(GenericAPIView):
281+
"""
282+
View to restore soft-deleted library containers.
283+
"""
284+
@convert_exceptions
285+
def post(self, request, container_key: LibraryContainerLocator) -> Response:
286+
"""
287+
Restores a soft-deleted library container
288+
"""
289+
api.require_permission_for_library_key(
290+
container_key.library_key,
291+
request.user,
292+
permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
293+
)
294+
api.restore_container(container_key)
295+
return Response(None, status=HTTP_204_NO_CONTENT)

openedx/core/djangoapps/content_libraries/tests/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file
3535
URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library
3636
URL_LIB_CONTAINER_COMPONENTS = URL_LIB_CONTAINER + 'children/' # Get, add or delete a component in this container
37+
URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container
3738

3839
URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/'
3940
URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
@@ -386,6 +387,10 @@ def _delete_container(self, container_key: str, expect_response=204):
386387
""" Delete a container (unit etc.) """
387388
return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response)
388389

390+
def _restore_container(self, container_key: str, expect_response=204):
391+
""" Restore a deleted a container (unit etc.) """
392+
return self._api('post', URL_LIB_CONTAINER_RESTORE.format(container_key=container_key), None, expect_response)
393+
389394
def _get_container_components(self, container_key: str, expect_response=200):
390395
""" Get container components"""
391396
return self._api(

openedx/core/djangoapps/content_libraries/tests/test_containers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,53 @@ def test_unit_replace_children(self):
330330
},
331331
update_receiver.call_args_list[0].kwargs,
332332
)
333+
334+
def test_restore_unit(self):
335+
"""
336+
Test restore a deleted unit.
337+
"""
338+
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
339+
lib_key = LibraryLocatorV2.from_string(lib["id"])
340+
341+
# Create a unit:
342+
create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc)
343+
with freeze_time(create_date):
344+
container_data = self._create_container(lib["id"], "unit", slug="u1", display_name="Test Unit")
345+
346+
# Delete the unit
347+
self._delete_container(container_data["container_key"])
348+
349+
create_receiver = mock.Mock()
350+
LIBRARY_CONTAINER_CREATED.connect(create_receiver)
351+
352+
# Restore container
353+
self._restore_container(container_data["container_key"])
354+
new_container_data = self._get_container(container_data["container_key"])
355+
expected_data = {
356+
"container_key": "lct:CL-TEST:containers:unit:u1",
357+
"container_type": "unit",
358+
"display_name": "Test Unit",
359+
"last_published": None,
360+
"published_by": "",
361+
"last_draft_created": "2024-09-08T07:06:05Z",
362+
"last_draft_created_by": 'Bob',
363+
'has_unpublished_changes': True,
364+
'created': '2024-09-08T07:06:05Z',
365+
'modified': '2024-09-08T07:06:05Z',
366+
'collections': [],
367+
}
368+
369+
self.assertDictContainsEntries(new_container_data, expected_data)
370+
371+
assert create_receiver.call_count == 1
372+
self.assertDictContainsSubset(
373+
{
374+
"signal": LIBRARY_CONTAINER_CREATED,
375+
"sender": None,
376+
"library_container": LibraryContainerData(
377+
lib_key,
378+
container_key="lct:CL-TEST:containers:unit:u1",
379+
),
380+
},
381+
create_receiver.call_args_list[0].kwargs,
382+
)

openedx/core/djangoapps/content_libraries/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
path('', containers.LibraryContainerView.as_view()),
8383
# update components under container
8484
path('children/', containers.LibraryContainerChildrenView.as_view()),
85+
# Restore a soft-deleted container
86+
path('restore/', containers.LibraryContainerRestore.as_view()),
8587
# Update collections for a given container
8688
# path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
8789
# path('publish/', views.LibraryContainerPublishView.as_view()),

0 commit comments

Comments
 (0)