33from functools import partial
44
55from 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
78from 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 )
0 commit comments