diff --git a/specifyweb/backend/businessrules/migrations/0002_default_unique_rules.py b/specifyweb/backend/businessrules/migrations/0002_default_unique_rules.py index ce308832494..e926cba165a 100644 --- a/specifyweb/backend/businessrules/migrations/0002_default_unique_rules.py +++ b/specifyweb/backend/businessrules/migrations/0002_default_unique_rules.py @@ -56,5 +56,5 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(apply_default_rules, - remove_default_rules, atomic=True), + remove_default_rules, atomic=True), ] diff --git a/specifyweb/backend/businessrules/tests/test_taxontreedefitem.py b/specifyweb/backend/businessrules/tests/test_taxontreedefitem.py index 123897020c4..60e1c7552bc 100644 --- a/specifyweb/backend/businessrules/tests/test_taxontreedefitem.py +++ b/specifyweb/backend/businessrules/tests/test_taxontreedefitem.py @@ -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): @@ -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()) diff --git a/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx index dd4a7a09823..b862f1610be 100644 --- a/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx @@ -283,4 +283,4 @@ function getGeoLocateData( ) ) ); -} +} \ No newline at end of file diff --git a/specifyweb/specify/migrations/0047_taxontreedefitem_parent_context_delete.py b/specifyweb/specify/migrations/0047_taxontreedefitem_parent_context_delete.py new file mode 100644 index 00000000000..7e895dc2db0 --- /dev/null +++ b/specifyweb/specify/migrations/0047_taxontreedefitem_parent_context_delete.py @@ -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', + ), + ), + ] diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 3560232bb95..75f754cbe93 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -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): + """ + 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 @@ -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: