Skip to content

Commit 758d08c

Browse files
kdmccormickclaude
andcommitted
fix: Handle Taxonomy CASCADE deletes in _is_explicit_tag_delete
When a Taxonomy is deleted, Django fires pre_delete for each related Tag with origin set to the Taxonomy instance. The previous code raised a TypeError for any non-Tag, non-QuerySet origin, breaking any consumer that deletes a Taxonomy. For a non-Tag-queryset origin (e.g. taxonomy.delete() or Taxonomy.objects.filter(...).delete()), we now emit only for root-level tags; their handler covers the whole subtree via lineage__startswith. Bumps to v1.0.1. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6d074b9 commit 758d08c

3 files changed

Lines changed: 18 additions & 15 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__ = "1.0.0"
9+
__version__ = "1.0.1"

‎src/openedx_tagging/signal_handlers.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,33 @@
1616

1717
def _is_explicit_tag_delete(
1818
instance: Tag,
19-
origin: Tag | QuerySet[Tag] | None,
19+
origin: object,
2020
using: str | None,
2121
) -> bool:
2222
"""
2323
Return True only for tags explicitly targeted by the delete operation.
2424
2525
Descendants deleted via CASCADE are skipped here because the explicit root
26-
tag's handler emits updates for the whole subtree.
26+
tag's handler emits updates for the whole subtree via lineage__startswith.
2727
2828
Args:
2929
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.
30+
origin: The source of the delete operation — a Tag instance (instance.delete()),
31+
a QuerySet[Tag] (queryset.delete()), or any other value when the delete
32+
was triggered by CASCADE from a parent model (e.g. taxonomy.delete()).
3233
using: The database alias to use for queries, passed from the Django signal.
3334
"""
3435
if isinstance(origin, Tag):
3536
return origin.pk == instance.pk
3637

37-
# Fail fast if origin has an unexpected type so callsites don't silently
38-
# skip event emission logic.
3938
if not isinstance(origin, QuerySet):
40-
raise TypeError(f"Expected origin to be Tag, QuerySet[Tag], or None; got {type(origin).__name__}")
39+
# CASCADE from a non-queryset origin (e.g., taxonomy.delete(), or None for unknown callers).
40+
# Only emit for root-level tags; the root handler covers the whole subtree via lineage__startswith.
41+
return len(instance.get_lineage()) == 1
4142
if origin.model is not Tag:
42-
raise TypeError(f"Expected origin queryset model Tag; got {origin.model.__name__}")
43+
# CASCADE from a queryset of a non-Tag model (e.g., Taxonomy.objects.filter(...).delete()).
44+
# Only emit for root-level tags; the root handler covers the whole subtree via lineage__startswith.
45+
return len(instance.get_lineage()) == 1
4346

4447
# Check if this instance is in the set of explicitly-targeted tags. If not, it's being deleted
4548
# as a CASCADE side-effect, so it's not explicit.

‎tests/openedx_tagging/test_models.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -676,12 +676,12 @@ def test_object_tag_export_id(self):
676676
self.object_tag.refresh_from_db()
677677
assert self.object_tag.export_id == "another-taxonomy"
678678

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")
679+
def test_is_explicit_tag_delete_taxonomy_cascade(self):
680+
# Root-level tag is treated as explicit when cascaded from a Taxonomy deletion,
681+
# so its handler covers the whole subtree via lineage__startswith.
682+
assert _is_explicit_tag_delete(instance=self.tag, origin=cast(Any, self.taxonomy), using="default") is True
683+
# Non-root tag returns False — its ancestor's handler already covers it.
684+
assert _is_explicit_tag_delete(instance=self.eubacteria, origin=cast(Any, self.taxonomy), using="default") is False
685685

686686
def test_object_tag_value(self):
687687
# ObjectTag's value defaults to its tag's value

0 commit comments

Comments
 (0)