Skip to content

Commit 5d1f96b

Browse files
refactor: remove ASYNC_DELETE_MAPPING, use FINDING_SCOPE_FILTERS
Replace the model_list-based mapping with a simple scope filter dict. prepare_duplicates_for_delete now accepts a single object and derives the scope via FINDING_SCOPE_FILTERS. Removes the redundant non-Finding model deletion loop — cascade_delete on the top-level object handles all remaining children. Cleans up async_delete class.
1 parent 2282fb6 commit 5d1f96b

4 files changed

Lines changed: 55 additions & 103 deletions

File tree

dojo/finding/helper.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -615,33 +615,28 @@ def reconfigure_duplicate_cluster(original, cluster_outside):
615615
cluster_outside.exclude(id=new_original.id).update(duplicate_finding=new_original)
616616

617617

618-
def prepare_duplicates_for_delete(test=None, engagement=None, product=None, product_type=None):
619-
logger.debug(
620-
"prepare duplicates for delete, test: %s, engagement: %s, product: %s, product_type: %s",
621-
test.id if test else None,
622-
engagement.id if engagement else None,
623-
product.id if product else None,
624-
product_type.id if product_type else None,
625-
)
626-
if test is None and engagement is None and product is None and product_type is None:
627-
logger.warning("nothing to prepare as no scope object provided")
618+
def prepare_duplicates_for_delete(obj):
619+
"""Prepare duplicate clusters before deleting a Test, Engagement, Product, or Product_Type.
620+
621+
Resets inside-scope duplicate FKs and reconfigures outside-scope clusters
622+
so that cascade_delete won't hit FK violations on the self-referential
623+
duplicate_finding field.
624+
"""
625+
from dojo.utils import FINDING_SCOPE_FILTERS # noqa: PLC0415 circular import
626+
627+
scope_field = FINDING_SCOPE_FILTERS.get(type(obj))
628+
if scope_field is None:
629+
logger.warning("prepare_duplicates_for_delete: unsupported object type %s", type(obj).__name__)
628630
return
629631

632+
logger.debug("prepare_duplicates_for_delete: %s %d", type(obj).__name__, obj.id)
633+
630634
# should not be needed in normal healthy instances.
631635
# but in that case it's a cheap count query and we might as well run it to be safe
632636
fix_loop_duplicates()
633637

634638
# Build scope as a subquery — never materialized into Python memory
635-
if product_type:
636-
scope_filter = {"test__engagement__product__prod_type": product_type}
637-
elif product:
638-
scope_filter = {"test__engagement__product": product}
639-
elif engagement:
640-
scope_filter = {"test__engagement": engagement}
641-
else:
642-
scope_filter = {"test": test}
643-
644-
scope_ids_subquery = Finding.objects.filter(**scope_filter).values_list("id", flat=True)
639+
scope_ids_subquery = Finding.objects.filter(**{scope_field: obj}).values_list("id", flat=True)
645640

646641
if not scope_ids_subquery.exists():
647642
logger.debug("no findings in scope, nothing to prepare")
@@ -679,7 +674,7 @@ def prepare_duplicates_for_delete(test=None, engagement=None, product=None, prod
679674
@receiver(pre_delete, sender=Test)
680675
def test_pre_delete(sender, instance, **kwargs):
681676
logger.debug("test pre_delete, sender: %s instance: %s", to_str_typed(sender), to_str_typed(instance))
682-
prepare_duplicates_for_delete(test=instance)
677+
prepare_duplicates_for_delete(instance)
683678

684679

685680
@receiver(post_delete, sender=Test)
@@ -690,7 +685,7 @@ def test_post_delete(sender, instance, **kwargs):
690685
@receiver(pre_delete, sender=Engagement)
691686
def engagement_pre_delete(sender, instance, **kwargs):
692687
logger.debug("engagement pre_delete, sender: %s instance: %s", to_str_typed(sender), to_str_typed(instance))
693-
prepare_duplicates_for_delete(engagement=instance)
688+
prepare_duplicates_for_delete(instance)
694689

695690

696691
@receiver(post_delete, sender=Engagement)

dojo/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1647,7 +1647,7 @@ def is_ci_cd(self):
16471647
def delete(self, *args, **kwargs):
16481648
logger.debug("%d engagement delete", self.id)
16491649
from dojo.finding import helper as finding_helper # noqa: PLC0415 circular import
1650-
finding_helper.prepare_duplicates_for_delete(engagement=self)
1650+
finding_helper.prepare_duplicates_for_delete(self)
16511651
super().delete(*args, **kwargs)
16521652
with suppress(Engagement.DoesNotExist, Product.DoesNotExist):
16531653
# Suppressing a potential issue created from async delete removing

dojo/utils.py

Lines changed: 24 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,23 +2026,16 @@ def is_finding_groups_enabled():
20262026
return get_system_setting("enable_finding_groups")
20272027

20282028

2029-
# Mapping of object types to their related models for cascading deletes
2030-
ASYNC_DELETE_MAPPING = {
2031-
"Product_Type": [
2032-
(Endpoint, "product__prod_type__id"),
2033-
(Finding, "test__engagement__product__prod_type__id"),
2034-
(Test, "engagement__product__prod_type__id"),
2035-
(Engagement, "product__prod_type__id"),
2036-
(Product, "prod_type__id")],
2037-
"Product": [
2038-
(Endpoint, "product__id"),
2039-
(Finding, "test__engagement__product__id"),
2040-
(Test, "engagement__product__id"),
2041-
(Engagement, "product__id")],
2042-
"Engagement": [
2043-
(Finding, "test__engagement__id"),
2044-
(Test, "engagement__id")],
2045-
"Test": [(Finding, "test__id")],
2029+
# Supported object types for async cascade deletion
2030+
ASYNC_DELETE_SUPPORTED_TYPES = (Product_Type, Product, Engagement, Test)
2031+
2032+
# Finding scope filters per model type — used to build the finding queryset
2033+
# for bulk deletion before cascade_delete handles the rest.
2034+
FINDING_SCOPE_FILTERS = {
2035+
Product_Type: "test__engagement__product__prod_type",
2036+
Product: "test__engagement__product",
2037+
Engagement: "test__engagement",
2038+
Test: "test",
20462039
}
20472040

20482041

@@ -2054,7 +2047,7 @@ def _get_object_name(obj):
20542047

20552048

20562049
@app.task
2057-
def async_delete_crawl_task(obj, model_list, **kwargs):
2050+
def async_delete_crawl_task(obj, **kwargs):
20582051
"""
20592052
Delete an object and all its related objects using the SQL cascade walker.
20602053
@@ -2083,26 +2076,14 @@ def async_delete_crawl_task(obj, model_list, **kwargs):
20832076
product = obj.engagement.product
20842077

20852078
# Step 1: Determine finding scope
2086-
finding_filter = None
2087-
for model, query_path in model_list:
2088-
if model is Finding:
2089-
finding_filter = {query_path: obj.id}
2090-
break
2091-
2092-
if finding_filter:
2093-
finding_qs = Finding.objects.filter(**finding_filter)
2079+
scope_field = FINDING_SCOPE_FILTERS.get(type(obj))
2080+
if scope_field:
2081+
finding_qs = Finding.objects.filter(**{scope_field: obj})
20942082

20952083
# Step 2: Prepare duplicate clusters (must happen before any deletion)
2096-
# When CASCADE_DELETE=True, reconfigure_duplicate_cluster skips deletion
2084+
# When CASCADE_DELETE=True, reconfigure_duplicate_cluster skips reconfiguration
20972085
# we handle that below by expanding scope to include outside duplicates.
2098-
if isinstance(obj, Product_Type):
2099-
prepare_duplicates_for_delete(product_type=obj)
2100-
elif isinstance(obj, Product):
2101-
prepare_duplicates_for_delete(product=obj)
2102-
elif isinstance(obj, Engagement):
2103-
prepare_duplicates_for_delete(engagement=obj)
2104-
elif isinstance(obj, Test):
2105-
prepare_duplicates_for_delete(test=obj)
2086+
prepare_duplicates_for_delete(obj)
21062087

21072088
# Step 3: Delete outside-scope duplicates first — these point to findings
21082089
# in the main scope via duplicate_finding FK, so they must be removed before
@@ -2119,22 +2100,14 @@ def async_delete_crawl_task(obj, model_list, **kwargs):
21192100
# Step 4: Delete the main scope findings
21202101
bulk_delete_findings(finding_qs, chunk_size=chunk_size)
21212102

2122-
# Delete remaining non-Finding children (Tests, Engagements, Endpoints)
2123-
# These are now lightweight since their Findings are gone
2124-
for model, query_path in model_list:
2125-
if model is not Finding:
2126-
filter_dict = {query_path: obj.id}
2127-
qs = model.objects.filter(**filter_dict)
2128-
if qs.exists():
2129-
with transaction.atomic():
2130-
cascade_delete(model, qs, skip_relations={Finding})
2131-
2132-
# Step 6: Delete the top-level object itself
2103+
# Step 5: Delete the top-level object and all remaining children (Tests,
2104+
# Engagements, Endpoints, etc.) via cascade_delete. Findings are already
2105+
# gone, so skip_relations={Finding} avoids walking empty relations.
21332106
pk_query = type(obj).objects.filter(pk=obj.pk)
21342107
with transaction.atomic():
21352108
cascade_delete(type(obj), pk_query, skip_relations={Finding})
21362109

2137-
# Step 7: Recalculate product grade once (not per-object)
2110+
# Step 6: Recalculate product grade once (not per-object)
21382111
# The custom delete() methods on Finding/Test/Engagement each call
21392112
# perform_product_grading — cascade_delete bypasses custom delete().
21402113
if product:
@@ -2153,14 +2126,11 @@ def async_delete_task(obj, **kwargs):
21532126
"""
21542127
from dojo.celery_dispatch import dojo_dispatch_task # noqa: PLC0415 circular import
21552128

2156-
logger.debug("ASYNC_DELETE: Deleting " + _get_object_name(obj) + ": " + str(obj))
2157-
model_list = ASYNC_DELETE_MAPPING.get(_get_object_name(obj))
2158-
if model_list:
2159-
# The object to be deleted was found in the object list
2160-
dojo_dispatch_task(async_delete_crawl_task, obj, model_list)
2129+
logger.debug("ASYNC_DELETE: Deleting %s: %s", _get_object_name(obj), obj)
2130+
if isinstance(obj, ASYNC_DELETE_SUPPORTED_TYPES):
2131+
dojo_dispatch_task(async_delete_crawl_task, obj)
21612132
else:
2162-
# The object is not supported in async delete, delete normally
2163-
logger.debug("ASYNC_DELETE: " + _get_object_name(obj) + " async delete not supported. Deleteing normally: " + str(obj))
2133+
logger.debug("ASYNC_DELETE: %s async delete not supported. Deleting normally: %s", _get_object_name(obj), obj)
21642134
obj.delete()
21652135

21662136

@@ -2177,10 +2147,6 @@ class async_delete:
21772147
which properly handles user context injection and pghistory context.
21782148
"""
21792149

2180-
def __init__(self, *args, **kwargs):
2181-
# Keep mapping reference for backwards compatibility
2182-
self.mapping = ASYNC_DELETE_MAPPING
2183-
21842150
def delete(self, obj, **kwargs):
21852151
"""
21862152
Entry point to delete an object asynchronously.
@@ -2192,18 +2158,10 @@ def delete(self, obj, **kwargs):
21922158

21932159
dojo_dispatch_task(async_delete_task, obj, **kwargs)
21942160

2195-
# Keep helper methods for backwards compatibility and potential direct use
21962161
@staticmethod
21972162
def get_object_name(obj):
21982163
return _get_object_name(obj)
21992164

2200-
@staticmethod
2201-
def chunk_list(model, full_list):
2202-
chunk_size = get_setting("ASYNC_OBEJECT_DELETE_CHUNK_SIZE")
2203-
chunk_list = [full_list[i:i + chunk_size] for i in range(0, len(full_list), chunk_size)]
2204-
logger.debug("ASYNC_DELETE: Split " + _get_object_name(model) + " into " + str(len(chunk_list)) + " chunks of " + str(chunk_size))
2205-
return chunk_list
2206-
22072165

22082166
@receiver(user_logged_in)
22092167
def log_user_login(sender, request, user, **kwargs):

unittests/test_prepare_duplicates_for_delete.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def test_no_duplicates(self):
104104
f2 = self._create_finding(self.test1, "F2")
105105

106106
with impersonate(self.testuser):
107-
prepare_duplicates_for_delete(test=self.test1)
107+
prepare_duplicates_for_delete(self.test1)
108108

109109
f1.refresh_from_db()
110110
f2.refresh_from_db()
@@ -120,7 +120,7 @@ def test_inside_scope_duplicates_reset(self):
120120
self._make_duplicate(dupe, original)
121121

122122
with impersonate(self.testuser):
123-
prepare_duplicates_for_delete(test=self.test1)
123+
prepare_duplicates_for_delete(self.test1)
124124

125125
dupe.refresh_from_db()
126126
self.assertIsNone(dupe.duplicate_finding)
@@ -137,7 +137,7 @@ def test_outside_scope_duplicates_get_new_original(self):
137137
self._make_duplicate(outside_dupe, original)
138138

139139
with impersonate(self.testuser):
140-
prepare_duplicates_for_delete(test=self.test1)
140+
prepare_duplicates_for_delete(self.test1)
141141

142142
outside_dupe.refresh_from_db()
143143
# Outside dupe becomes the new original
@@ -158,7 +158,7 @@ def test_outside_scope_cluster_repointed(self):
158158
self._make_duplicate(dupe_d, original)
159159

160160
with impersonate(self.testuser):
161-
prepare_duplicates_for_delete(test=self.test1)
161+
prepare_duplicates_for_delete(self.test1)
162162

163163
dupe_b.refresh_from_db()
164164
dupe_c.refresh_from_db()
@@ -182,7 +182,7 @@ def test_engagement_scope_inside_reset(self):
182182
self._make_duplicate(dupe, original)
183183

184184
with impersonate(self.testuser):
185-
prepare_duplicates_for_delete(engagement=self.engagement1)
185+
prepare_duplicates_for_delete(self.engagement1)
186186

187187
dupe.refresh_from_db()
188188
self.assertIsNone(dupe.duplicate_finding)
@@ -195,7 +195,7 @@ def test_engagement_scope_outside_reconfigure(self):
195195
self._make_duplicate(outside_dupe, original)
196196

197197
with impersonate(self.testuser):
198-
prepare_duplicates_for_delete(engagement=self.engagement1)
198+
prepare_duplicates_for_delete(self.engagement1)
199199

200200
outside_dupe.refresh_from_db()
201201
self.assertFalse(outside_dupe.duplicate)
@@ -210,7 +210,7 @@ def test_mixed_inside_and_outside_duplicates(self):
210210
self._make_duplicate(outside_dupe, original)
211211

212212
with impersonate(self.testuser):
213-
prepare_duplicates_for_delete(test=self.test1)
213+
prepare_duplicates_for_delete(self.test1)
214214

215215
inside_dupe.refresh_from_db()
216216
outside_dupe.refresh_from_db()
@@ -235,7 +235,7 @@ def test_cascade_delete_skips_outside_reconfigure(self):
235235
self._make_duplicate(outside_dupe, original)
236236

237237
with impersonate(self.testuser):
238-
prepare_duplicates_for_delete(test=self.test1)
238+
prepare_duplicates_for_delete(self.test1)
239239

240240
outside_dupe.refresh_from_db()
241241
# Outside dupe is still a duplicate — not reconfigured or deleted
@@ -252,7 +252,7 @@ def test_multiple_originals(self):
252252
self._make_duplicate(dupe_of_b, original_b)
253253

254254
with impersonate(self.testuser):
255-
prepare_duplicates_for_delete(test=self.test1)
255+
prepare_duplicates_for_delete(self.test1)
256256

257257
dupe_of_a.refresh_from_db()
258258
dupe_of_b.refresh_from_db()
@@ -274,7 +274,7 @@ def test_original_status_copied_to_new_original(self):
274274
self._make_duplicate(outside_dupe, original)
275275

276276
with impersonate(self.testuser):
277-
prepare_duplicates_for_delete(test=self.test1)
277+
prepare_duplicates_for_delete(self.test1)
278278

279279
outside_dupe.refresh_from_db()
280280
self.assertFalse(outside_dupe.duplicate)
@@ -292,7 +292,7 @@ def test_found_by_copied_to_new_original(self):
292292
self._make_duplicate(outside_dupe, original)
293293

294294
with impersonate(self.testuser):
295-
prepare_duplicates_for_delete(test=self.test1)
295+
prepare_duplicates_for_delete(self.test1)
296296

297297
outside_dupe.refresh_from_db()
298298
found_by_ids = set(outside_dupe.found_by.values_list("id", flat=True))
@@ -337,7 +337,7 @@ def test_delete_product_with_cross_engagement_duplicates(self):
337337
Action: delete the entire product via async_delete_crawl_task.
338338
Expected: product and all findings are deleted without errors.
339339
"""
340-
from dojo.utils import ASYNC_DELETE_MAPPING, async_delete_crawl_task
340+
from dojo.utils import async_delete_crawl_task
341341

342342
finding_a = self._create_finding(self.test1, "Original A")
343343
finding_a.active = True
@@ -351,9 +351,8 @@ def test_delete_product_with_cross_engagement_duplicates(self):
351351
finding_a_id = finding_a.id
352352
finding_b_id = finding_b.id
353353

354-
model_list = ASYNC_DELETE_MAPPING["Product"]
355354
with impersonate(self.testuser):
356-
async_delete_crawl_task(self.product, model_list)
355+
async_delete_crawl_task(self.product)
357356

358357
# Everything should be gone
359358
self.assertFalse(Product.objects.filter(id=product_id).exists())

0 commit comments

Comments
 (0)