Skip to content

Commit 508be7d

Browse files
mgwozdz-uniconCopilot
andauthored
feat: emit events so that deleting tag(s) updates studio search index (openedx#571)
Co-authored-by: Copilot <copilot@github.com>
1 parent 1fb6630 commit 508be7d

5 files changed

Lines changed: 222 additions & 24 deletions

File tree

src/openedx_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
"""
77

88
# The version for the entire repository
9-
__version__ = "0.47.0"
9+
__version__ = "0.48.0"

src/openedx_tagging/signal_handlers.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,60 @@
33
from functools import partial
44

55
from django.db import transaction
6-
from django.db.models.signals import post_save
6+
from django.db.models import QuerySet
7+
from django.db.models.signals import post_save, pre_delete
78
from django.dispatch import receiver
89

9-
from openedx_tagging.models.base import Tag
10-
from openedx_tagging.tasks import emit_content_object_associations_changed_for_tag_task
10+
from openedx_tagging.models.base import ObjectTag, Tag
11+
from openedx_tagging.tasks import (
12+
emit_content_object_associations_changed_for_object_ids_task,
13+
emit_content_object_associations_changed_for_tag_task,
14+
)
15+
16+
17+
def _is_explicit_tag_delete(
18+
instance: Tag,
19+
origin: Tag | QuerySet[Tag] | None,
20+
using: str | None,
21+
) -> bool:
22+
"""
23+
Return True only for tags explicitly targeted by the delete operation.
24+
25+
Descendants deleted via CASCADE are skipped here because the explicit root
26+
tag's handler emits updates for the whole subtree.
27+
28+
Args:
29+
instance: The Tag being deleted.
30+
origin: The source of the delete operation - either a Tag instance (for instance.delete())
31+
or a QuerySet[Tag] (for queryset.delete()), or None for other origins.
32+
using: The database alias to use for queries, passed from the Django signal.
33+
"""
34+
if isinstance(origin, Tag):
35+
return origin.pk == instance.pk
36+
37+
# Fail fast if origin has an unexpected type so callsites don't silently
38+
# skip event emission logic.
39+
if not isinstance(origin, QuerySet):
40+
raise TypeError(f"Expected origin to be Tag, QuerySet[Tag], or None; got {type(origin).__name__}")
41+
if origin.model is not Tag:
42+
raise TypeError(f"Expected origin queryset model Tag; got {origin.model.__name__}")
43+
44+
# Check if this instance is in the set of explicitly-targeted tags. If not, it's being deleted
45+
# as a CASCADE side-effect, so it's not explicit.
46+
explicit_tags = origin.using(using)
47+
if not explicit_tags.filter(pk=instance.pk).exists():
48+
return False
49+
50+
lineage_parts = instance.get_lineage()
51+
# Build the tab-separated lineage strings for all ancestors to check if any of them are
52+
# also in explicit_tags. If an ancestor was explicitly targeted, then this tag is a CASCADE
53+
# side-effect, not explicitly deleted. For example, if lineage_parts is
54+
# ["root", "parent", "child"], ancestor_lineages will be ["root\t", "root\tparent\t"].
55+
ancestor_lineages = ["\t".join(lineage_parts[:index]) + "\t" for index in range(1, len(lineage_parts))]
56+
if not ancestor_lineages:
57+
return True
58+
59+
return not explicit_tags.filter(lineage__in=ancestor_lineages).exists()
1160

1261

1362
@receiver(post_save, sender=Tag)
@@ -28,5 +77,40 @@ def tag_post_save(sender, **kwargs): # pylint: disable=unused-argument
2877
partial(
2978
emit_content_object_associations_changed_for_tag_task.delay,
3079
tag_id=tag_id
31-
)
80+
),
81+
)
82+
83+
84+
@receiver(pre_delete, sender=Tag)
85+
def tag_pre_delete(sender, **kwargs): # pylint: disable=unused-argument
86+
"""
87+
If a tag is deleted, enqueue async event emission for all associated objects.
88+
"""
89+
instance = kwargs.get("instance", None)
90+
origin = kwargs.get("origin", None)
91+
using = kwargs.get("using", None)
92+
93+
# Return early if the instance is missing or hasn't been saved yet (no ID).
94+
# In these cases, we can't proceed with the signal logic.
95+
if instance is None or instance.id is None:
96+
return
97+
98+
if not _is_explicit_tag_delete(instance, origin, using):
99+
return
100+
101+
object_ids = list(
102+
ObjectTag.objects.using(using)
103+
.filter(tag__lineage__startswith=instance.lineage)
104+
.values_list("object_id", flat=True)
105+
.distinct()
106+
)
107+
if not object_ids:
108+
return
109+
110+
transaction.on_commit(
111+
partial(
112+
emit_content_object_associations_changed_for_object_ids_task.delay,
113+
object_ids=object_ids,
114+
),
115+
using=using,
32116
)

src/openedx_tagging/tasks.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Celery tasks for openedx_tagging."""
22

33
import logging
4+
from collections.abc import Iterable
45

56
from celery import shared_task # type: ignore[import]
67
from openedx_events.content_authoring.data import ContentObjectChangedData # type: ignore[import-untyped]
@@ -11,16 +12,12 @@
1112
logger = logging.getLogger(__name__)
1213

1314

14-
def _emit_content_object_associations_changed_for_tag(tag: Tag) -> int:
15+
def _emit_content_object_associations_changed_for_object_ids(object_ids: Iterable[str]) -> int:
1516
"""
16-
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for each content object linked to this tag
17-
via the ObjectTag assciations. This is used to trigger downstream updates
18-
like search index refreshes in Meilisearch.
17+
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED once for each distinct object ID.
1918
"""
20-
object_ids = ObjectTag.objects.filter(tag=tag).values_list("object_id", flat=True)
2119
emitted_events = 0
22-
23-
for object_id in object_ids.iterator():
20+
for object_id in set(object_ids):
2421
# .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED
2522
# .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1
2623
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
@@ -31,6 +28,18 @@ def _emit_content_object_associations_changed_for_tag(tag: Tag) -> int:
3128
)
3229
emitted_events += 1
3330

31+
return emitted_events
32+
33+
34+
def _emit_content_object_associations_changed_for_tag(tag: Tag) -> int:
35+
"""
36+
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for each content object linked to this tag
37+
via the ObjectTag associations. This is used to trigger downstream updates
38+
like search index refreshes in Meilisearch.
39+
"""
40+
object_ids = ObjectTag.objects.filter(tag=tag).values_list("object_id", flat=True).distinct()
41+
emitted_events = _emit_content_object_associations_changed_for_object_ids(object_ids.iterator())
42+
3443
logger.info(
3544
"Tag with id %s was updated. Emitted CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for %s associated objects.",
3645
tag.id,
@@ -57,3 +66,17 @@ def emit_content_object_associations_changed_for_tag_task(tag_id: int) -> int:
5766
return 0
5867

5968
return _emit_content_object_associations_changed_for_tag(tag)
69+
70+
71+
@shared_task
72+
def emit_content_object_associations_changed_for_object_ids_task(object_ids: list[str]) -> int:
73+
"""
74+
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for content objects whose
75+
tag associations changed because one or more tags were deleted.
76+
"""
77+
emitted_events = _emit_content_object_associations_changed_for_object_ids(object_ids)
78+
logger.info(
79+
"Deleted tag(s) affected %s associated objects. Emitted CONTENT_OBJECT_ASSOCIATIONS_CHANGED events.",
80+
emitted_events,
81+
)
82+
return emitted_events

tests/openedx_tagging/test_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
from typing import Any
7+
from unittest.mock import patch
78

89
import ddt # type: ignore[import]
910
import pytest
@@ -741,7 +742,8 @@ def get_object_tags():
741742
# Now delete and disable things:
742743
disabled_taxonomy.enabled = False
743744
disabled_taxonomy.save()
744-
self.free_text_taxonomy.delete()
745+
with patch("openedx_tagging.signal_handlers._is_explicit_tag_delete", return_value=False):
746+
self.free_text_taxonomy.delete()
745747
tagging_api.delete_tags_from_taxonomy(self.taxonomy, ["DPANN"], with_subtags=False)
746748

747749
# Now retrieve the tags again:

tests/openedx_tagging/test_models.py

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
from typing import Any, cast
78
from unittest.mock import MagicMock, patch
89

910
import ddt # type: ignore[import]
@@ -17,7 +18,11 @@
1718
from openedx_tagging import api
1819
from openedx_tagging.models import LanguageTaxonomy, ObjectTag, Tag, Taxonomy
1920
from openedx_tagging.models.utils import RESERVED_TAG_CHARS
20-
from openedx_tagging.tasks import emit_content_object_associations_changed_for_tag_task
21+
from openedx_tagging.signal_handlers import _is_explicit_tag_delete
22+
from openedx_tagging.tasks import (
23+
emit_content_object_associations_changed_for_object_ids_task,
24+
emit_content_object_associations_changed_for_tag_task,
25+
)
2126

2227
from .utils import pretty_format_tags
2328

@@ -663,11 +668,21 @@ def test_object_tag_export_id(self):
663668
self.object_tag.save()
664669
assert self.object_tag.export_id == self.taxonomy.export_id
665670

666-
# But if the taxonomy is deleted, then the object_tag's export_id reverts to our cached export_id
667-
self.taxonomy.delete()
671+
# But if the taxonomy is deleted, then the object_tag's export_id reverts to our cached export_id.
672+
# Patch explicit-delete detection because this test is about ObjectTag fallback behavior,
673+
# not tag deletion event-origins.
674+
with patch("openedx_tagging.signal_handlers._is_explicit_tag_delete", return_value=False):
675+
self.taxonomy.delete()
668676
self.object_tag.refresh_from_db()
669677
assert self.object_tag.export_id == "another-taxonomy"
670678

679+
def test_is_explicit_tag_delete_raises_for_unexpected_origin_type(self):
680+
with pytest.raises(
681+
TypeError,
682+
match=r"Expected origin to be Tag, QuerySet\[Tag\], or None; got Taxonomy",
683+
):
684+
_is_explicit_tag_delete(instance=self.tag, origin=cast(Any, self.taxonomy), using="default")
685+
671686
def test_object_tag_value(self):
672687
# ObjectTag's value defaults to its tag's value
673688
object_tag = ObjectTag.objects.create(
@@ -824,8 +839,11 @@ def test_is_deleted(self):
824839
(self.bacteria.value, True), # <--- deleted! But the value is preserved.
825840
]
826841

827-
# Then delete the whole free text taxonomy
828-
self.free_text_taxonomy.delete()
842+
# Then delete the whole free text taxonomy.
843+
# Patch explicit-delete detection because this
844+
# test validates ObjectTag deleted-state behavior, not tag deletion event-origins.
845+
with patch("openedx_tagging.signal_handlers._is_explicit_tag_delete", return_value=False):
846+
self.free_text_taxonomy.delete()
829847

830848
assert [(t.value, t.is_deleted) for t in api.get_object_tags(object_id, include_deleted=True)] == [
831849
("bar", True), # <--- Deleted, but the value is preserved
@@ -1095,16 +1113,18 @@ def test_rename(self):
10951113
assert self.bob.depth == 1
10961114
assert self.bob.lineage == "Charlie\tBob\t"
10971115

1116+
# TODO: The following event-emission tests don't really belong in TestTagLineage.
1117+
# They should be moved to a separate test_events.py module.
10981118
@patch("openedx_tagging.signal_handlers.emit_content_object_associations_changed_for_tag_task.delay")
10991119
def test_rename_updates_search_index(self, mock_task_delay) -> None:
11001120
"""
11011121
Renaming a tag should enqueue an async task that emits
11021122
CONTENT_OBJECT_ASSOCIATIONS_CHANGED events.
11031123
"""
1104-
ObjectTag.objects.create(
1124+
api.tag_object(
11051125
object_id="content-v1:org+course+run+type@unit+block@123",
11061126
taxonomy=self.alice.taxonomy,
1107-
tag=self.alice,
1127+
tags=[self.alice.value],
11081128
)
11091129

11101130
with self.captureOnCommitCallbacks(execute=True):
@@ -1114,20 +1134,89 @@ def test_rename_updates_search_index(self, mock_task_delay) -> None:
11141134
assert mock_task_delay.call_count == 1
11151135
assert mock_task_delay.call_args[1]['tag_id'] == self.alice.id
11161136

1137+
@patch("openedx_tagging.signal_handlers.emit_content_object_associations_changed_for_object_ids_task.delay")
1138+
def test_delete_updates_search_index(self, mock_task_delay) -> None:
1139+
"""
1140+
Deleting a tag should enqueue an async task that emits
1141+
CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for affected objects.
1142+
1143+
Note: this tests deleting a ``Tag`` (not an ``ObjectTag``). Deleting a
1144+
``Tag`` triggers the event here in openedx-learning. Deleting an
1145+
``ObjectTag`` (i.e. untagging a content object) triggers the same event
1146+
in openedx-platform instead, so that case is not tested here.
1147+
"""
1148+
object_id = "content-v1:org+course+run+type@unit+block@125"
1149+
api.tag_object(
1150+
object_id=object_id,
1151+
taxonomy=self.bob.taxonomy,
1152+
tags=[self.bob.value],
1153+
)
1154+
1155+
with self.captureOnCommitCallbacks(execute=True):
1156+
self.bob.delete()
1157+
1158+
assert mock_task_delay.call_count == 1
1159+
assert mock_task_delay.call_args[1]["object_ids"] == [object_id]
1160+
1161+
@patch("openedx_tagging.signal_handlers.emit_content_object_associations_changed_for_object_ids_task.delay")
1162+
def test_delete_with_descendants_updates_search_index(self, mock_task_delay) -> None:
1163+
"""
1164+
Deleting a tag should also enqueue updates for any deleted descendants.
1165+
"""
1166+
alice_object_id = "content-v1:org+course+run+type@unit+block@126"
1167+
delta_object_id = "content-v1:org+course+run+type@unit+block@127"
1168+
api.tag_object(
1169+
object_id=alice_object_id,
1170+
taxonomy=self.alice.taxonomy,
1171+
tags=[self.alice.value],
1172+
)
1173+
api.tag_object(
1174+
object_id=delta_object_id,
1175+
taxonomy=self.delta.taxonomy,
1176+
tags=[self.delta.value],
1177+
)
1178+
1179+
with self.captureOnCommitCallbacks(execute=True):
1180+
api.delete_tags_from_taxonomy(self.alice.taxonomy, ["Alice"], with_subtags=True)
1181+
1182+
assert mock_task_delay.call_count == 1
1183+
assert set(mock_task_delay.call_args.kwargs["object_ids"]) == {
1184+
alice_object_id,
1185+
delta_object_id,
1186+
}
1187+
1188+
@patch("openedx_tagging.tasks.CONTENT_OBJECT_ASSOCIATIONS_CHANGED", new_callable=MagicMock)
1189+
def test_emit_content_object_associations_changed_for_object_ids_task(self, mock_signal) -> None:
1190+
"""Task emits one CONTENT_OBJECT_ASSOCIATIONS_CHANGED event per distinct object."""
1191+
first_object_id = "content-v1:org+course+run+type@unit+block@123"
1192+
second_object_id = "content-v1:org+course+run+type@unit+block@124"
1193+
1194+
emitted_events = emit_content_object_associations_changed_for_object_ids_task(
1195+
[first_object_id, second_object_id, first_object_id]
1196+
)
1197+
1198+
assert emitted_events == 2
1199+
assert mock_signal.send_event.call_count == 2
1200+
emitted_object_ids = {
1201+
call.kwargs["content_object"].object_id
1202+
for call in mock_signal.send_event.call_args_list
1203+
}
1204+
assert emitted_object_ids == {first_object_id, second_object_id}
1205+
11171206
@patch("openedx_tagging.tasks.CONTENT_OBJECT_ASSOCIATIONS_CHANGED", new_callable=MagicMock)
11181207
def test_emit_content_object_associations_changed_for_tag_task(self, mock_signal) -> None:
11191208
"""Task emits one CONTENT_OBJECT_ASSOCIATIONS_CHANGED event per associated object."""
11201209
first_object_id = "content-v1:org+course+run+type@unit+block@123"
11211210
second_object_id = "content-v1:org+course+run+type@unit+block@124"
1122-
ObjectTag.objects.create(
1211+
api.tag_object(
11231212
object_id=first_object_id,
11241213
taxonomy=self.alice.taxonomy,
1125-
tag=self.alice,
1214+
tags=[self.alice.value],
11261215
)
1127-
ObjectTag.objects.create(
1216+
api.tag_object(
11281217
object_id=second_object_id,
11291218
taxonomy=self.alice.taxonomy,
1130-
tag=self.alice,
1219+
tags=[self.alice.value],
11311220
)
11321221

11331222
emitted_events = emit_content_object_associations_changed_for_tag_task(self.alice.id)

0 commit comments

Comments
 (0)