Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@ class Migration(migrations.Migration):

operations = [
migrations.RunPython(apply_default_rules,
remove_default_rules, atomic=True),
remove_default_rules, atomic=True),
]
34 changes: 31 additions & 3 deletions specifyweb/backend/businessrules/tests/test_taxontreedefitem.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
from django.db.models import ProtectedError
from specifyweb.specify.tests.test_api import ApiTests
from ..exceptions import TreeBusinessRuleException
from specifyweb.specify import models

class TaxonTreeDefItemTests(ApiTests):
Expand Down Expand Up @@ -30,5 +28,35 @@ def test_delete_blocked_by_taxon(self):
name="Animals",
definition=kingdom.treedef,
rankid=kingdom.rankid)

animals.delete()

def test_delete_unused_rank_reparents_children(self):
kingdom = self.roottaxontreedefitem.children.create(
name="Kingdom",
treedef=self.taxontreedef,
rankid=self.roottaxontreedefitem.rankid+100)
phylum = kingdom.children.create(
name="Phylum",
treedef=self.taxontreedef,
rankid=kingdom.rankid+100)

models.Taxontreedefitem.objects.filter(id=kingdom.id).delete()

phylum.refresh_from_db()
self.assertEqual(phylum.parent_id, self.roottaxontreedefitem.id)
self.assertFalse(models.Taxontreedefitem.objects.filter(id=kingdom.id).exists())

def test_full_tree_delete_still_cascades(self):
kingdom = self.roottaxontreedefitem.children.create(
name="Kingdom",
treedef=self.taxontreedef,
rankid=self.roottaxontreedefitem.rankid+100)
kingdom.treeentries.create(
parent=self.roottaxon,
name="Animals",
definition=kingdom.treedef,
rankid=kingdom.rankid)

self.taxontreedef.delete()

self.assertFalse(models.Taxontreedef.objects.filter(id=self.taxontreedef.id).exists())
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,4 @@ function getGeoLocateData(
)
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 4.2.24 on 2026-07-29

from django.db import migrations, models
import specifyweb.specify.models


class Migration(migrations.Migration):

dependencies = [
('specify', '0046_alter_specifyuser_spprincipals'),
]

operations = [
migrations.AlterField(
model_name='taxontreedefitem',
name='parent',
field=models.ForeignKey(
db_column='ParentItemID',
null=True,
on_delete=specifyweb.specify.models.delete_taxon_rank_parent_with_context,
related_name='children',
to='specify.taxontreedefitem',
),
),
]
53 changes: 52 additions & 1 deletion specifyweb/specify/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,57 @@ def protect_with_blockers(collector, field, sub_objs, using):
else:
return models.PROTECT(collector, field, sub_objs, using)


def _get_collector_model_instances(collector, model_name):
deleting_models = getattr(collector, 'data', {})
for model, objs in deleting_models.items():
if getattr(model, '__name__', '').lower() == model_name.lower():
return list(objs)
return []


def _reparent_taxon_rank_children(ranks):
rank_list = list(ranks)
if not rank_list:
return

deleting_rank_ids = {rank.id for rank in rank_list}
for rank in rank_list:
Taxontreedefitem.objects.filter(parent_id=rank.id)\
.exclude(id__in=deleting_rank_ids)\
.update(parent_id=rank.parent_id)


def delete_taxon_rank_parent_with_context(collector, field, sub_objs, using):
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"""
Use CASCADE while deleting an entire TaxonTreeDef.

For single-rank deletion, reparent child ranks before deleting so
ParentItemID remains valid.
"""
deleting_models = getattr(collector, 'data', {})
is_tree_delete = any(
getattr(model, '__name__', '').lower() == 'taxontreedef'
for model in deleting_models.keys()
)
if is_tree_delete:
return models.CASCADE(collector, field, sub_objs, using)

processed_ids = getattr(collector, '_taxon_rank_delete_prepared_ids', set())
ranks_to_delete = [
rank
for rank in _get_collector_model_instances(collector, 'taxontreedefitem')
if rank.id not in processed_ids
]
_reparent_taxon_rank_children(ranks_to_delete)

if ranks_to_delete:
collector._taxon_rank_delete_prepared_ids = processed_ids.union(
{rank.id for rank in ranks_to_delete}
)

return None

def custom_save(self, *args, **kwargs):
try:
# Custom save logic here, if necessary
Expand Down Expand Up @@ -7337,7 +7388,7 @@ class Taxontreedefitem(model_extras.Taxontreedefitem):
# Relationships: Many-to-One
createdbyagent = models.ForeignKey('Agent', db_column='CreatedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers)
modifiedbyagent = models.ForeignKey('Agent', db_column='ModifiedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers)
parent = models.ForeignKey('TaxonTreeDefItem', db_column='ParentItemID', related_name='children', null=True, on_delete=models.CASCADE)
parent = models.ForeignKey('TaxonTreeDefItem', db_column='ParentItemID', related_name='children', null=True, on_delete=delete_taxon_rank_parent_with_context)
treedef = models.ForeignKey('TaxonTreeDef', db_column='TaxonTreeDefID', related_name='treedefitems', null=False, on_delete=models.CASCADE)

class Meta:
Expand Down
Loading