Skip to content

Commit 9d61902

Browse files
refactor: rename cascade_delete to cascade_delete_related_objects
The function now only deletes related objects, not the root record. This allows async_delete_task to call obj.delete() on the top-level object via ORM, which fires Django signals (post_delete notifications, pghistory audit, Pro signals like product_post_delete). bulk_delete_findings uses execute_delete_sql to delete the finding rows themselves after cascade_delete_related_objects cleans children.
1 parent 44caf6d commit 9d61902

3 files changed

Lines changed: 41 additions & 25 deletions

File tree

dojo/finding/helper.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,10 @@ def bulk_delete_findings(finding_qs, chunk_size=1000):
770770
Chunked with per-chunk transaction.atomic() for crash safety.
771771
"""
772772
from dojo.signals import pre_bulk_delete_findings # noqa: PLC0415 circular import
773-
from dojo.utils_cascade_delete import cascade_delete # noqa: PLC0415 circular import
773+
from dojo.utils_cascade_delete import ( # noqa: PLC0415 circular import
774+
cascade_delete_related_objects,
775+
execute_delete_sql,
776+
)
774777

775778
pre_bulk_delete_findings.send(sender=Finding, finding_qs=finding_qs)
776779
bulk_clear_finding_m2m(finding_qs)
@@ -782,8 +785,10 @@ def bulk_delete_findings(finding_qs, chunk_size=1000):
782785
),
783786
start=1,
784787
):
788+
chunk_qs = Finding.objects.filter(id__in=chunk_ids)
785789
with transaction.atomic():
786-
cascade_delete(Finding, Finding.objects.filter(id__in=chunk_ids), skip_relations={Finding}, skip_m2m_for={Finding})
790+
cascade_delete_related_objects(Finding, chunk_qs, skip_relations={Finding}, skip_m2m_for={Finding})
791+
execute_delete_sql(chunk_qs)
787792
logger.info(
788793
"bulk_delete_findings: deleted chunk %d (%d findings)",
789794
chunk_num, len(chunk_ids),

dojo/utils.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2052,8 +2052,9 @@ def async_delete_task(obj, **kwargs):
20522052
Delete an object and all its related objects using the SQL cascade walker.
20532053
20542054
Handles Python-level concerns (duplicates, integrators, M2M, file cleanup,
2055-
product grading) explicitly, then uses cascade_delete() for efficient
2056-
bottom-up SQL deletion of all FK-related tables.
2055+
product grading) explicitly, then uses cascade_delete_related_objects() for
2056+
efficient bottom-up SQL deletion of all FK-related tables. The top-level
2057+
object is deleted via ORM obj.delete() to fire Django signals.
20572058
20582059
Accepts **kwargs for _pgh_context injected by dojo_dispatch_task.
20592060
Uses PgHistoryTask base class (default) to preserve pghistory context for audit trail.
@@ -2062,7 +2063,7 @@ def async_delete_task(obj, **kwargs):
20622063
bulk_delete_findings,
20632064
prepare_duplicates_for_delete,
20642065
)
2065-
from dojo.utils_cascade_delete import cascade_delete # noqa: PLC0415 circular import
2066+
from dojo.utils_cascade_delete import cascade_delete_related_objects # noqa: PLC0415 circular import
20662067

20672068
logger.debug("ASYNC_DELETE: Deleting %s: %s", _get_object_name(obj), obj)
20682069
if not isinstance(obj, ASYNC_DELETE_SUPPORTED_TYPES):
@@ -2108,18 +2109,21 @@ def async_delete_task(obj, **kwargs):
21082109
# Step 4: Delete the main scope findings
21092110
bulk_delete_findings(finding_qs, chunk_size=chunk_size)
21102111

2111-
# Step 5: Delete the top-level object and all remaining children (Tests,
2112-
# Engagements, Endpoints, etc.) via cascade_delete. Findings are already
2113-
# gone, so skip_relations={Finding} avoids walking empty relations.
2112+
# Step 5: Delete all remaining related objects (Tests, Engagements,
2113+
# Endpoints, etc.) via SQL cascade. Findings are already gone, so
2114+
# skip_relations={Finding} avoids walking empty relations.
21142115
# Single transaction is fine here — the heavy relations (Findings,
21152116
# Endpoint_Status) are already deleted; only lightweight rows remain.
21162117
pk_query = type(obj).objects.filter(pk=obj.pk)
21172118
with transaction.atomic():
2118-
cascade_delete(type(obj), pk_query, skip_relations={Finding})
2119+
cascade_delete_related_objects(type(obj), pk_query, skip_relations={Finding})
21192120

2120-
# Step 6: Recalculate product grade once (not per-object)
2121-
# The custom delete() methods on Finding/Test/Engagement each call
2122-
# perform_product_grading — cascade_delete bypasses custom delete().
2121+
# Step 6: Delete the top-level object via ORM to fire Django signals
2122+
# (post_delete notifications, pghistory audit, Pro signals).
2123+
# All children are already gone so this is a single-row DELETE.
2124+
obj.delete()
2125+
2126+
# Step 7: Recalculate product grade once (not per-object)
21232127
if product:
21242128
perform_product_grading(product)
21252129

dojo/utils_cascade_delete.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,15 @@ def execute_update_sql(query, **updatespec):
6060
return execute_compiled_sql(*get_update_sql(query, **updatespec))
6161

6262

63-
def cascade_delete(from_model, instance_pk_query, skip_relations=None, skip_m2m_for=None, base_model=None, level=0):
63+
def cascade_delete_related_objects(from_model, instance_pk_query, skip_relations=None, skip_m2m_for=None, base_model=None, level=0):
6464
"""
6565
Recursively walk Django model relations and execute compiled SQL
66-
to perform cascade DELETE / SET_NULL without the Collector.
66+
to perform cascade DELETE / SET_NULL on related objects without the Collector.
6767
68-
Walks from_model._meta.related_objects to discover all FK relations,
69-
recurses into CASCADE children first (bottom-up), then deletes at the
70-
current level. No query execution until recursion unwinds.
68+
At level 0 (the root), only related objects are deleted — the root records
69+
themselves are NOT deleted. This allows the caller to use ORM obj.delete()
70+
on the root to fire Django signals (notifications, audit, etc.).
71+
At deeper levels, records are deleted after their children.
7172
7273
Includes any related object in Dojo-Pro
7374
@@ -81,7 +82,7 @@ def cascade_delete(from_model, instance_pk_query, skip_relations=None, skip_m2m_
8182
level: Recursion depth (for logging only).
8283
8384
Returns:
84-
Number of records deleted at this level.
85+
Number of records deleted at this level (0 at level 0 since root is not deleted).
8586
8687
"""
8788
if skip_relations is None:
@@ -131,7 +132,7 @@ def cascade_delete(from_model, instance_pk_query, skip_relations=None, skip_m2m_
131132
related_model._meta.pk.name,
132133
)
133134
# Recurse into children first (bottom-up deletion)
134-
cascade_delete(
135+
cascade_delete_related_objects(
135136
related_model, related_pk_query,
136137
skip_relations=skip_relations,
137138
skip_m2m_for=skip_m2m_for,
@@ -180,16 +181,22 @@ def cascade_delete(from_model, instance_pk_query, skip_relations=None, skip_m2m_
180181
m2m_count, through_model._meta.db_table,
181182
)
182183

183-
# After all children and M2M are deleted, delete records at this level
184+
# At level 0, do NOT delete root records — the caller handles that
185+
# (e.g. via ORM obj.delete() to fire Django signals).
184186
if level == 0:
185-
del_query = instance_pk_query
186-
else:
187-
filterspec = {f"{from_model._meta.pk.name}__in": models.Subquery(instance_pk_query)}
188-
del_query = from_model.objects.filter(**filterspec)
187+
logger.debug(
188+
"cascade_delete_related_objects level 0: related objects deleted for %s (root not deleted)",
189+
from_model.__name__,
190+
)
191+
return 0
192+
193+
# At deeper levels, delete records after their children are gone
194+
filterspec = {f"{from_model._meta.pk.name}__in": models.Subquery(instance_pk_query)}
195+
del_query = from_model.objects.filter(**filterspec)
189196

190197
count = execute_delete_sql(del_query)
191198
logger.debug(
192-
"cascade_delete level %d: deleted %d %s records",
199+
"cascade_delete_related_objects level %d: deleted %d %s records",
193200
level, count, from_model.__name__,
194201
)
195202
return count

0 commit comments

Comments
 (0)