Skip to content

Commit 7eed8c6

Browse files
authored
fix: error during paste could create un-deletable unit in library (#38161)
* fix: Keep sync search index There may be cases where entries are created in the search index, but the component/container is not created (an intermediate error occurred). In that case, we added a fix to keep the index updated by removing the entry. * test: Tests added for delete component/contantainer from search index * fix: Create docs in search index on commit transaction - Only in: set_library_block_olx, _import_staged_block, create_container
1 parent 9e75ebe commit 7eed8c6

3 files changed

Lines changed: 224 additions & 25 deletions

File tree

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

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -255,25 +255,26 @@ def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) ->
255255

256256
# .. event_implemented_name: LIBRARY_BLOCK_UPDATED
257257
# .. event_type: org.openedx.content_authoring.library_block.updated.v1
258-
LIBRARY_BLOCK_UPDATED.send_event(
258+
transaction.on_commit(lambda: LIBRARY_BLOCK_UPDATED.send_event(
259259
library_block=LibraryBlockData(
260260
library_key=usage_key.context_key,
261261
usage_key=usage_key
262262
)
263-
)
263+
))
264264

265265
# For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger
266266
# container indexing asynchronously.
267267
affected_containers = get_containers_contains_item(usage_key)
268268
for container in affected_containers:
269269
# .. event_implemented_name: LIBRARY_CONTAINER_UPDATED
270270
# .. event_type: org.openedx.content_authoring.content_library.container.updated.v1
271-
LIBRARY_CONTAINER_UPDATED.send_event(
271+
container_key = container.container_key
272+
transaction.on_commit(lambda ck=container_key: LIBRARY_CONTAINER_UPDATED.send_event( # type: ignore[misc]
272273
library_container=LibraryContainerData(
273-
container_key=container.container_key,
274+
container_key=ck,
274275
background=True,
275276
)
276-
)
277+
))
277278

278279
return new_component_version
279280

@@ -496,12 +497,12 @@ def _import_staged_block(
496497
# Emit library block created event
497498
# .. event_implemented_name: LIBRARY_BLOCK_CREATED
498499
# .. event_type: org.openedx.content_authoring.library_block.created.v1
499-
LIBRARY_BLOCK_CREATED.send_event(
500+
transaction.on_commit(lambda: LIBRARY_BLOCK_CREATED.send_event(
500501
library_block=LibraryBlockData(
501502
library_key=content_library.library_key,
502503
usage_key=usage_key
503504
)
504-
)
505+
))
505506

506507
# Now return the metadata about the new block
507508
return get_library_block(usage_key)
@@ -702,21 +703,35 @@ def delete_library_block(
702703
"""
703704
Delete the specified block from this library (soft delete).
704705
"""
705-
component = get_component_from_usage_key(usage_key)
706706
library_key = usage_key.context_key
707+
708+
def send_block_deleted_signal():
709+
# .. event_implemented_name: LIBRARY_BLOCK_DELETED
710+
# .. event_type: org.openedx.content_authoring.library_block.deleted.v1
711+
LIBRARY_BLOCK_DELETED.send_event(
712+
library_block=LibraryBlockData(
713+
library_key=library_key,
714+
usage_key=usage_key
715+
)
716+
)
717+
718+
try:
719+
component = get_component_from_usage_key(usage_key)
720+
except Component.DoesNotExist:
721+
# There may be cases where entries are created in the
722+
# search index, but the component is not created
723+
# (an intermediate error occurred).
724+
# In that case, we keep the index updated by removing the entry,
725+
# but still raise the error so the caller knows the component did not exist.
726+
send_block_deleted_signal()
727+
raise
728+
707729
affected_collections = content_api.get_entity_collections(component.learning_package_id, component.key)
708730
affected_containers = get_containers_contains_item(usage_key)
709731

710732
content_api.soft_delete_draft(component.pk, deleted_by=user_id)
711733

712-
# .. event_implemented_name: LIBRARY_BLOCK_DELETED
713-
# .. event_type: org.openedx.content_authoring.library_block.deleted.v1
714-
LIBRARY_BLOCK_DELETED.send_event(
715-
library_block=LibraryBlockData(
716-
library_key=library_key,
717-
usage_key=usage_key
718-
)
719-
)
734+
send_block_deleted_signal()
720735

721736
# For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
722737
# collection indexing asynchronously.

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

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from uuid import uuid4
1010
import typing
1111

12+
from django.db import transaction
1213
from django.utils.text import slugify
1314
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
1415
from openedx_events.content_authoring.data import (
@@ -134,7 +135,11 @@ def create_container(
134135

135136
# .. event_implemented_name: LIBRARY_CONTAINER_CREATED
136137
# .. event_type: org.openedx.content_authoring.content_library.container.created.v1
137-
LIBRARY_CONTAINER_CREATED.send_event(library_container=LibraryContainerData(container_key=container_key))
138+
transaction.on_commit(lambda: LIBRARY_CONTAINER_CREATED.send_event(
139+
library_container=LibraryContainerData(
140+
container_key=container_key,
141+
)
142+
))
138143

139144
return ContainerMetadata.from_container(library_key, container)
140145

@@ -200,8 +205,27 @@ def delete_container(
200205
201206
No-op if container doesn't exist or has already been soft-deleted.
202207
"""
208+
def send_container_deleted_signal():
209+
# .. event_implemented_name: LIBRARY_CONTAINER_DELETED
210+
# .. event_type: org.openedx.content_authoring.content_library.container.deleted.v1
211+
LIBRARY_CONTAINER_DELETED.send_event(
212+
library_container=LibraryContainerData(
213+
container_key=container_key,
214+
)
215+
)
216+
217+
try:
218+
container = get_container_from_key(container_key)
219+
except Container.DoesNotExist:
220+
# There may be cases where entries are created in the
221+
# search index, but the container is not created
222+
# (an intermediate error occurred).
223+
# In that case, we keep the index updated by removing the entry,
224+
# but still raise the error so the caller knows the container did not exist.
225+
send_container_deleted_signal()
226+
raise
227+
203228
library_key = container_key.lib_key
204-
container = get_container_from_key(container_key)
205229

206230
# Fetch related collections and containers before soft-delete
207231
affected_collections = content_api.get_entity_collections(
@@ -216,13 +240,7 @@ def delete_container(
216240
)
217241
content_api.soft_delete_draft(container.pk)
218242

219-
# .. event_implemented_name: LIBRARY_CONTAINER_DELETED
220-
# .. event_type: org.openedx.content_authoring.content_library.container.deleted.v1
221-
LIBRARY_CONTAINER_DELETED.send_event(
222-
library_container=LibraryContainerData(
223-
container_key=container_key,
224-
)
225-
)
243+
send_container_deleted_signal()
226244

227245
# For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
228246
# collection indexing asynchronously.

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import uuid
88
from unittest import mock
99

10+
from django.db import transaction
1011
from django.test import TestCase
1112
from user_tasks.models import UserTaskStatus
1213

@@ -18,16 +19,23 @@
1819
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
1920
from openedx_events.content_authoring.data import (
2021
ContentObjectChangedData,
22+
LibraryBlockData,
2123
LibraryCollectionData,
2224
LibraryContainerData,
2325
)
2426
from openedx_events.content_authoring.signals import (
2527
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
28+
LIBRARY_BLOCK_CREATED,
29+
LIBRARY_BLOCK_DELETED,
30+
LIBRARY_BLOCK_UPDATED,
2631
LIBRARY_COLLECTION_CREATED,
2732
LIBRARY_COLLECTION_DELETED,
2833
LIBRARY_COLLECTION_UPDATED,
34+
LIBRARY_CONTAINER_CREATED,
35+
LIBRARY_CONTAINER_DELETED,
2936
LIBRARY_CONTAINER_UPDATED,
3037
)
38+
from openedx_content.models_api import Component, Container
3139
from openedx_authz.api.users import get_user_role_assignments_in_scope
3240
from openedx_content import api as content_api
3341
from openedx_content import models_api as content_models
@@ -660,6 +668,69 @@ def test_delete_library_container(self) -> None:
660668
},
661669
)
662670

671+
def test_delete_container_when_container_does_not_exist(self) -> None:
672+
"""
673+
Test that delete_container raises Container.DoesNotExist and still sends
674+
LIBRARY_CONTAINER_DELETED (to clean up stale search-index entries) when
675+
the Container does not exist in the DB.
676+
"""
677+
container_key = LibraryContainerLocator.from_string(self.unit1["id"])
678+
679+
event_receiver = mock.Mock()
680+
LIBRARY_CONTAINER_DELETED.connect(event_receiver)
681+
self.addCleanup(LIBRARY_CONTAINER_DELETED.disconnect, event_receiver)
682+
683+
with mock.patch(
684+
"openedx.core.djangoapps.content_libraries.api.containers.get_container_from_key",
685+
side_effect=Container.DoesNotExist,
686+
), mock.patch("openedx_content.api.soft_delete_draft") as mock_soft_delete:
687+
with self.assertRaises(Container.DoesNotExist):
688+
api.delete_container(container_key)
689+
mock_soft_delete.assert_not_called()
690+
691+
assert event_receiver.call_count == 1
692+
self.assertDictContainsEntries(
693+
event_receiver.call_args_list[0].kwargs,
694+
{
695+
"signal": LIBRARY_CONTAINER_DELETED,
696+
"library_container": LibraryContainerData(
697+
container_key=container_key,
698+
),
699+
},
700+
)
701+
702+
def test_delete_library_block_when_component_does_not_exist(self) -> None:
703+
"""
704+
Test that delete_library_block raises Component.DoesNotExist and still sends
705+
LIBRARY_BLOCK_DELETED (to clean up stale search-index entries) when the
706+
Component does not exist in the DB.
707+
"""
708+
usage_key = LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"])
709+
710+
event_receiver = mock.Mock()
711+
LIBRARY_BLOCK_DELETED.connect(event_receiver)
712+
self.addCleanup(LIBRARY_BLOCK_DELETED.disconnect, event_receiver)
713+
714+
with mock.patch(
715+
"openedx.core.djangoapps.content_libraries.api.blocks.get_component_from_usage_key",
716+
side_effect=Component.DoesNotExist,
717+
), mock.patch("openedx_content.api.soft_delete_draft") as mock_soft_delete:
718+
with self.assertRaises(Component.DoesNotExist):
719+
api.delete_library_block(usage_key)
720+
mock_soft_delete.assert_not_called()
721+
722+
assert event_receiver.call_count == 1
723+
self.assertDictContainsEntries(
724+
event_receiver.call_args_list[0].kwargs,
725+
{
726+
"signal": LIBRARY_BLOCK_DELETED,
727+
"library_block": LibraryBlockData(
728+
library_key=self.lib1.library_key,
729+
usage_key=usage_key,
730+
),
731+
},
732+
)
733+
663734
def test_restore_library_block(self) -> None:
664735
api.update_library_collection_items(
665736
self.lib1.library_key,
@@ -1391,6 +1462,101 @@ def test_copy_and_paste_container_another_library(self) -> None:
13911462
# This is the same unit, so it should not be duplicated
13921463
assert units_subsection1[0].container_key == units_subsection2[0].container_key
13931464

1465+
def test_set_library_block_olx_no_signal_on_rollback(self) -> None:
1466+
"""
1467+
LIBRARY_BLOCK_UPDATED is NOT emitted when set_library_block_olx is called
1468+
within a transaction that is later rolled back.
1469+
"""
1470+
event_receiver = mock.Mock()
1471+
LIBRARY_BLOCK_UPDATED.connect(event_receiver)
1472+
self.addCleanup(LIBRARY_BLOCK_UPDATED.disconnect, event_receiver)
1473+
1474+
try:
1475+
with transaction.atomic():
1476+
api.set_library_block_olx(
1477+
self.problem_block_usage_key,
1478+
"<problem>Updated inside rolled-back transaction</problem>",
1479+
)
1480+
raise RuntimeError("Force rollback")
1481+
except RuntimeError:
1482+
pass
1483+
1484+
assert event_receiver.call_count == 0
1485+
1486+
def test_set_library_block_olx_signal_emitted_on_success(self) -> None:
1487+
"""
1488+
LIBRARY_BLOCK_UPDATED IS emitted when set_library_block_olx completes
1489+
successfully.
1490+
"""
1491+
event_receiver = mock.Mock()
1492+
LIBRARY_BLOCK_UPDATED.connect(event_receiver)
1493+
self.addCleanup(LIBRARY_BLOCK_UPDATED.disconnect, event_receiver)
1494+
1495+
api.set_library_block_olx(
1496+
self.problem_block_usage_key,
1497+
"<problem>Updated successfully</problem>",
1498+
)
1499+
1500+
assert event_receiver.call_count == 1
1501+
self.assertDictContainsEntries(
1502+
event_receiver.call_args_list[0].kwargs,
1503+
{
1504+
"signal": LIBRARY_BLOCK_UPDATED,
1505+
"library_block": LibraryBlockData(
1506+
library_key=self.lib1.library_key,
1507+
usage_key=self.problem_block_usage_key,
1508+
),
1509+
},
1510+
)
1511+
1512+
def test_import_container_no_signals_on_failure(self) -> None:
1513+
"""
1514+
When import_staged_content_from_user_clipboard fails mid-way, none of
1515+
LIBRARY_CONTAINER_CREATED, LIBRARY_BLOCK_CREATED, or LIBRARY_BLOCK_UPDATED
1516+
are emitted, so the search index is not polluted with orphan entries.
1517+
"""
1518+
api.copy_container(self.unit1.container_key, self.user.id)
1519+
1520+
event_receiver = mock.Mock()
1521+
for signal in [LIBRARY_CONTAINER_CREATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_UPDATED]:
1522+
signal.connect(event_receiver)
1523+
self.addCleanup(signal.disconnect, event_receiver)
1524+
1525+
# Simulate a failure at the last step of the import (after the container
1526+
# and its child components have been created in the DB).
1527+
with mock.patch(
1528+
"openedx.core.djangoapps.content_libraries.api.blocks.update_container_children",
1529+
side_effect=RuntimeError("Simulated failure"),
1530+
), self.assertRaises(RuntimeError):
1531+
api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user)
1532+
1533+
assert event_receiver.call_count == 0
1534+
1535+
def test_import_container_signals_emitted_on_success(self) -> None:
1536+
"""
1537+
When import_staged_content_from_user_clipboard succeeds, LIBRARY_CONTAINER_CREATED
1538+
is emitted for the new container.
1539+
"""
1540+
api.copy_container(self.unit1.container_key, self.user.id)
1541+
1542+
container_created_receiver = mock.Mock()
1543+
LIBRARY_CONTAINER_CREATED.connect(container_created_receiver)
1544+
self.addCleanup(LIBRARY_CONTAINER_CREATED.disconnect, container_created_receiver)
1545+
1546+
new_container = api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user)
1547+
1548+
assert container_created_receiver.call_count == 1
1549+
assert hasattr(new_container, "container_key")
1550+
self.assertDictContainsEntries(
1551+
container_created_receiver.call_args_list[0].kwargs,
1552+
{
1553+
"signal": LIBRARY_CONTAINER_CREATED,
1554+
"library_container": LibraryContainerData(
1555+
container_key=new_container.container_key, # type: ignore[union-attr]
1556+
),
1557+
},
1558+
)
1559+
13941560

13951561
class ContentLibraryExportTest(ContentLibrariesRestApiTest):
13961562
"""

0 commit comments

Comments
 (0)