Skip to content

Commit a3aa91e

Browse files
authored
Merge pull request #14630 from valentijnscholten/fix-reverse-m2m-cascade-delete
fix: clear reverse M2M through tables before cascade deletion
2 parents 9fdce53 + c374cfa commit a3aa91e

3 files changed

Lines changed: 99 additions & 26 deletions

File tree

dojo/finding/helper.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -726,11 +726,18 @@ def bulk_clear_finding_m2m(finding_qs):
726726
# Remove tags with proper count maintenance
727727
bulk_remove_all_tags(Finding, finding_ids)
728728

729-
# Auto-discover and delete remaining (non-tag) M2M through tables
730-
for m2m_field in Finding._meta.many_to_many:
731-
if hasattr(m2m_field, "tag_options"):
729+
# Auto-discover and delete M2M through tables — both forward (Finding._meta.many_to_many)
730+
# and reverse (other models with ManyToManyField pointing to Finding, e.g. Finding_Group.findings).
731+
# Forward M2M fields use field.remote_field.through, reverse use field.through.
732+
m2m_through_models = set()
733+
for field_info in Finding._meta.get_fields():
734+
if hasattr(field_info, "tag_options"):
732735
continue
733-
through_model = m2m_field.remote_field.through
736+
through = getattr(field_info, "through", None) or getattr(getattr(field_info, "remote_field", None), "through", None)
737+
if through is not None:
738+
m2m_through_models.add(through)
739+
740+
for through_model in m2m_through_models:
734741
# Find the FK column that points to Finding
735742
fk_column = None
736743
for field in through_model._meta.get_fields():

dojo/utils_cascade_delete.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -159,27 +159,32 @@ def cascade_delete_related_objects(from_model, instance_pk_query, skip_relations
159159

160160
bulk_remove_all_tags(from_model, instance_pk_query)
161161

162-
for m2m_field in from_model._meta.many_to_many:
163-
# Skip tag fields — handled by bulk_remove_all_tags above
164-
if hasattr(m2m_field, "tag_options"):
165-
continue
166-
# Skip if caller already cleaned M2M for this model
167-
if from_model in skip_m2m_for:
168-
continue
169-
through_model = m2m_field.remote_field.through
170-
fk_column = None
171-
for field in through_model._meta.get_fields():
172-
if hasattr(field, "related_model") and field.related_model is from_model:
173-
fk_column = field.column
174-
break
175-
if fk_column:
176-
filterspec_m2m = {f"{fk_column}__in": models.Subquery(instance_pk_query)}
177-
m2m_count = execute_delete_sql(through_model.objects.filter(**filterspec_m2m))
178-
if m2m_count:
179-
logger.debug(
180-
"cascade_delete: cleared %d rows from M2M %s",
181-
m2m_count, through_model._meta.db_table,
182-
)
162+
# Clear all M2M through tables — both forward (from_model._meta.many_to_many)
163+
# and reverse (other models with ManyToManyField pointing to from_model).
164+
# Forward M2M fields use field.remote_field.through, reverse use field.through.
165+
if from_model not in skip_m2m_for:
166+
m2m_through_models = set()
167+
for field_info in from_model._meta.get_fields():
168+
if hasattr(field_info, "tag_options"):
169+
continue
170+
through = getattr(field_info, "through", None) or getattr(getattr(field_info, "remote_field", None), "through", None)
171+
if through is not None:
172+
m2m_through_models.add(through)
173+
174+
for through_model in m2m_through_models:
175+
fk_column = None
176+
for field in through_model._meta.get_fields():
177+
if hasattr(field, "related_model") and field.related_model is from_model:
178+
fk_column = field.column
179+
break
180+
if fk_column:
181+
filterspec_m2m = {f"{fk_column}__in": models.Subquery(instance_pk_query)}
182+
m2m_count = execute_delete_sql(through_model.objects.filter(**filterspec_m2m))
183+
if m2m_count:
184+
logger.debug(
185+
"cascade_delete: cleared %d rows from M2M %s",
186+
m2m_count, through_model._meta.db_table,
187+
)
183188

184189
# At level 0, do NOT delete root records — the caller handles that
185190
# (e.g. via ORM obj.delete() to fire Django signals).

unittests/test_prepare_duplicates_for_delete.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,19 @@
1313
from django.utils import timezone
1414

1515
from dojo.finding.helper import prepare_duplicates_for_delete
16-
from dojo.models import Engagement, Finding, Product, Product_Type, Test, Test_Type, User, UserContactInfo
16+
from dojo.models import (
17+
Dojo_User,
18+
Engagement,
19+
Finding,
20+
Finding_Group,
21+
Product,
22+
Product_Type,
23+
Risk_Acceptance,
24+
Test,
25+
Test_Type,
26+
User,
27+
UserContactInfo,
28+
)
1729

1830
from .dojo_test_case import DojoTestCase
1931

@@ -409,3 +421,52 @@ def test_delete_product_with_tags(self):
409421
self.assertEqual(product_shared_tag.count, 0)
410422
finding_shared_tag.refresh_from_db()
411423
self.assertEqual(finding_shared_tag.count, 0)
424+
425+
def test_delete_product_with_reverse_m2m_relations(self):
426+
"""
427+
Deleting a product with findings that have reverse M2M relations succeeds.
428+
429+
Reverse M2M through tables (M2M fields on other models pointing to Finding)
430+
must be cleared before findings are deleted. This tests:
431+
- Finding_Group.findings (dojo_finding_group_findings)
432+
- Risk_Acceptance.accepted_findings (dojo_risk_acceptance_accepted_findings)
433+
"""
434+
from dojo.utils import async_delete # noqa: PLC0415
435+
436+
finding_a = self._create_finding(self.test1, "Grouped Finding A")
437+
finding_b = self._create_finding(self.test1, "Grouped Finding B")
438+
finding_c = self._create_finding(self.test1, "Risk Accepted Finding")
439+
440+
# Finding_Group with findings
441+
creator = Dojo_User.objects.first() or Dojo_User.objects.create(username="testcreator")
442+
group = Finding_Group.objects.create(
443+
name="Test Group",
444+
test=self.test1,
445+
creator=creator,
446+
)
447+
group.findings.add(finding_a, finding_b)
448+
449+
# Risk_Acceptance with accepted findings
450+
ra = Risk_Acceptance.objects.create(
451+
name="Test RA",
452+
owner=self.testuser,
453+
)
454+
ra.accepted_findings.add(finding_c)
455+
# Link to engagement so we can verify it survives
456+
self.engagement1.risk_acceptance.add(ra)
457+
458+
product_id = self.product.id
459+
group_id = group.id
460+
ra_id = ra.id
461+
462+
with impersonate(self.testuser):
463+
async_del = async_delete()
464+
async_del.delete(self.product)
465+
466+
self.assertFalse(Product.objects.filter(id=product_id).exists())
467+
self.assertFalse(Finding_Group.objects.filter(id=group_id).exists())
468+
self.assertFalse(Finding.objects.filter(id__in=[finding_a.id, finding_b.id, finding_c.id]).exists())
469+
# Risk_Acceptance itself survives (no FK to product/engagement),
470+
# but its accepted_findings M2M entries should be gone
471+
self.assertTrue(Risk_Acceptance.objects.filter(id=ra_id).exists())
472+
self.assertEqual(Risk_Acceptance.objects.get(id=ra_id).accepted_findings.count(), 0)

0 commit comments

Comments
 (0)