From 7ef2199d9f551fe7b7e36a604796ff11f5fd051d Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Wed, 22 Apr 2026 20:01:17 +0200 Subject: [PATCH 1/3] Add bulk delete findings extension hook and optional cascade context - Split internal bulk delete implementation from entry point that can delegate via get_custom_method(BULK_DELETE_FINDINGS_METHOD). - Allow callers to pass optional cascade_root alongside chunk_size for downstream use when a custom method is configured. --- dojo/finding/helper.py | 16 +++++++++++++++- dojo/utils.py | 6 ++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index ba11567869b..0e9575d2338 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -764,7 +764,7 @@ def bulk_clear_finding_m2m(finding_qs): Notes.objects.filter(id__in=note_ids).delete() -def bulk_delete_findings(finding_qs, chunk_size=1000): +def _bulk_delete_findings_internal(finding_qs, chunk_size=1000): """ Delete findings and all related objects efficiently. Including any related object in Dojo-Pro @@ -799,6 +799,20 @@ def bulk_delete_findings(finding_qs, chunk_size=1000): ) +def bulk_delete_findings(finding_qs, chunk_size=1000, cascade_root=None): + """Entry point; may delegate to Pro via settings.BULK_DELETE_FINDINGS_METHOD. + + cascade_root: optional dict describing the top-level object whose cascade triggered + this bulk delete (e.g. {"model": "dojo.engagement", "pk": 9}). Ignored by OSS + when no custom method is configured. + """ + from dojo.utils import get_custom_method # noqa: PLC0415 circular import + + if fn := get_custom_method("BULK_DELETE_FINDINGS_METHOD"): + return fn(finding_qs, chunk_size=chunk_size, cascade_root=cascade_root) + return _bulk_delete_findings_internal(finding_qs, chunk_size=chunk_size) + + def fix_loop_duplicates(scope_qs=None): """Due to bugs in the past and even currently when under high parallel load, there can be transitive duplicates.""" """ i.e. A -> B -> C. This can lead to problems when deleting findingns, performing deduplication, etc """ diff --git a/dojo/utils.py b/dojo/utils.py index 0e528b67d5d..b546c662ddb 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -2024,6 +2024,8 @@ def async_delete_task(obj, **kwargs): scope_field = FINDING_SCOPE_FILTERS.get(type(obj)) if scope_field: finding_qs = Finding.objects.filter(**{scope_field: obj}) + # cascade_root is some context we provide to the bulk_delete_findings function + cascade_root = {"model": obj._meta.label_lower, "pk": obj.pk} # Step 2: Prepare duplicate clusters (must happen before any deletion) # When CASCADE_DELETE=True, reconfigure_duplicate_cluster skips reconfiguration — @@ -2042,10 +2044,10 @@ def async_delete_task(obj, **kwargs): outside_count = outside_dupes_qs.count() if outside_count: logger.info("ASYNC_DELETE: Deleting %d outside-scope duplicates first", outside_count) - bulk_delete_findings(outside_dupes_qs, chunk_size=chunk_size) + bulk_delete_findings(outside_dupes_qs, chunk_size=chunk_size, cascade_root=cascade_root) # Step 4: Delete the main scope findings - bulk_delete_findings(finding_qs, chunk_size=chunk_size) + bulk_delete_findings(finding_qs, chunk_size=chunk_size, cascade_root=cascade_root) # Step 5: Delete all remaining related objects (Tests, Engagements, # Endpoints, etc.) via SQL cascade. Findings are already gone, so From ef97786b4eba681725d3dca8b82293a2af0728d8 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Wed, 22 Apr 2026 20:15:06 +0200 Subject: [PATCH 2/3] Fix docstring layout for bulk_delete_findings (Ruff D213) --- dojo/finding/helper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index 0e9575d2338..f66dc1bff20 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -800,7 +800,8 @@ def _bulk_delete_findings_internal(finding_qs, chunk_size=1000): def bulk_delete_findings(finding_qs, chunk_size=1000, cascade_root=None): - """Entry point; may delegate to Pro via settings.BULK_DELETE_FINDINGS_METHOD. + """ + Entry point; may delegate to Pro via settings.BULK_DELETE_FINDINGS_METHOD. cascade_root: optional dict describing the top-level object whose cascade triggered this bulk delete (e.g. {"model": "dojo.engagement", "pk": 9}). Ignored by OSS From e5d6651bd214d78ad17583cba9c149c32c27bff4 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Wed, 22 Apr 2026 20:26:27 +0200 Subject: [PATCH 3/3] Pass product_id into bulk_delete_findings from async delete - Extend bulk_delete_findings entry point with optional product_id for custom methods. - async_delete_task: resolve product once for grading and metering; pass product_id to bulk_delete_findings; skip perform_product_grading when deleting a Product. --- dojo/finding/helper.py | 12 ++++++++++-- dojo/utils.py | 23 ++++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index f66dc1bff20..b3c3031f241 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -799,18 +799,26 @@ def _bulk_delete_findings_internal(finding_qs, chunk_size=1000): ) -def bulk_delete_findings(finding_qs, chunk_size=1000, cascade_root=None): +def bulk_delete_findings(finding_qs, chunk_size=1000, cascade_root=None, product_id=None): """ Entry point; may delegate to Pro via settings.BULK_DELETE_FINDINGS_METHOD. cascade_root: optional dict describing the top-level object whose cascade triggered this bulk delete (e.g. {"model": "dojo.engagement", "pk": 9}). Ignored by OSS when no custom method is configured. + + product_id: optional owning product id for callers that already know scope (e.g. + async cascade delete). Ignored by OSS when no custom method is configured. """ from dojo.utils import get_custom_method # noqa: PLC0415 circular import if fn := get_custom_method("BULK_DELETE_FINDINGS_METHOD"): - return fn(finding_qs, chunk_size=chunk_size, cascade_root=cascade_root) + return fn( + finding_qs, + chunk_size=chunk_size, + cascade_root=cascade_root, + product_id=product_id, + ) return _bulk_delete_findings_internal(finding_qs, chunk_size=chunk_size) diff --git a/dojo/utils.py b/dojo/utils.py index b546c662ddb..1a08256528e 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -2015,7 +2015,9 @@ def async_delete_task(obj, **kwargs): # Capture product reference before deletion for product grading at the end product = None with suppress(Product.DoesNotExist, Engagement.DoesNotExist, Test.DoesNotExist): - if isinstance(obj, Engagement): + if isinstance(obj, Product): + product = obj + elif isinstance(obj, Engagement): product = obj.product elif isinstance(obj, Test): product = obj.engagement.product @@ -2044,10 +2046,20 @@ def async_delete_task(obj, **kwargs): outside_count = outside_dupes_qs.count() if outside_count: logger.info("ASYNC_DELETE: Deleting %d outside-scope duplicates first", outside_count) - bulk_delete_findings(outside_dupes_qs, chunk_size=chunk_size, cascade_root=cascade_root) + bulk_delete_findings( + outside_dupes_qs, + chunk_size=chunk_size, + cascade_root=cascade_root, + product_id=product.pk if product else None, + ) # Step 4: Delete the main scope findings - bulk_delete_findings(finding_qs, chunk_size=chunk_size, cascade_root=cascade_root) + bulk_delete_findings( + finding_qs, + chunk_size=chunk_size, + cascade_root=cascade_root, + product_id=product.pk if product else None, + ) # Step 5: Delete all remaining related objects (Tests, Engagements, # Endpoints, etc.) via SQL cascade. Findings are already gone, so @@ -2063,8 +2075,9 @@ def async_delete_task(obj, **kwargs): # All children are already gone so this is a single-row DELETE. obj.delete() - # Step 7: Recalculate product grade once (not per-object) - if product: + # Step 7: Recalculate product grade once (Engagement/Test deletes only). Skip when the + # deleted object is the Product itself — it is removed in step 6 and grading is pointless. + if product and not isinstance(obj, Product): perform_product_grading(product) logger.info("ASYNC_DELETE: Successfully deleted %s: %s", obj_name, obj)