From c3b895fef208bc4e0669788d47abb700253d8243 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 14 May 2026 22:45:53 -0500 Subject: [PATCH 1/2] fix(trees): handle synonymization in save --- .../backend/businessrules/tests/test_taxon.py | 202 ++++++++++++++++++ specifyweb/specify/models.py | 112 +++++++++- 2 files changed, 313 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/businessrules/tests/test_taxon.py b/specifyweb/backend/businessrules/tests/test_taxon.py index 415eddb798c..1b3b3b8a027 100644 --- a/specifyweb/backend/businessrules/tests/test_taxon.py +++ b/specifyweb/backend/businessrules/tests/test_taxon.py @@ -195,3 +195,205 @@ def test_accepted_children_acceptedparent_set_to_null_on_delete(self): self.assertEqual( models.Taxon.objects.get(id=acceptedchild.id).acceptedtaxon, None) + + def test_determination_preferredtaxon_updates_on_synonymization(self): + """Test that Determinations are updated when a Taxon is synonymized""" + kingdom = self.roottaxontreedefitem.children.create( + name="Kingdom", + treedef=self.taxontreedef) + + # Create two taxa: one that will be a synonym, one that will be the accepted + synonym = self.roottaxon.children.create( + name="OldName", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + accepted = self.roottaxon.children.create( + name="NewName", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + # Create a determination with the synonym taxon + det = self.collectionobjects[0].determinations.create( + collectionmemberid=self.collection.id, + iscurrent=True, + taxon=synonym, + preferredtaxon=synonym + ) + + # Verify initial state + self.assertEqual(det.preferredtaxon.id, synonym.id) + + # Synonymize: set synonym's acceptedtaxon to accepted + synonym.acceptedtaxon = accepted + synonym.save() + + # Refresh from DB + det.refresh_from_db() + + # PreferredTaxon should now be the accepted taxon + self.assertEqual(det.preferredtaxon.id, accepted.id) + + def test_determination_preferredtaxon_updates_on_desynonymization(self): + """Test that Determinations are updated when a Taxon is desynonymized""" + kingdom = self.roottaxontreedefitem.children.create( + name="Kingdom", + treedef=self.taxontreedef) + + # Create two taxa: one will be a synonym, one will be the accepted + accepted = self.roottaxon.children.create( + name="NewName", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + synonym = self.roottaxon.children.create( + name="OldName", + acceptedtaxon=accepted, + definition=self.taxontreedef, + definitionitem=kingdom + ) + + # Create a determination with the accepted taxon but via the synonym + det = self.collectionobjects[0].determinations.create( + collectionmemberid=self.collection.id, + iscurrent=True, + taxon=synonym, + preferredtaxon=accepted + ) + + # Verify initial state (preferredtaxon points to accepted) + self.assertEqual(det.preferredtaxon.id, accepted.id) + + # Desynonymize: clear acceptedtaxon on synonym + synonym.acceptedtaxon = None + synonym.save() + + # Refresh from DB + det.refresh_from_db() + + # PreferredTaxon should now be the synonym (same as taxon) + self.assertEqual(det.preferredtaxon.id, synonym.id) + self.assertEqual(det.taxon.id, synonym.id) + + def test_acceptedchildren_repointed_on_synonymization(self): + """Test that acceptedchildren are repointed when a Taxon is synonymized""" + kingdom = self.roottaxontreedefitem.children.create( + name="Kingdom", + treedef=self.taxontreedef) + + # Create three taxa: one that will be synonymized, one accepted, and one child + # that points to the first as its accepted taxon + synonym = self.roottaxon.children.create( + name="OldName", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + accepted = self.roottaxon.children.create( + name="NewName", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + # Create a child taxon that has 'synonym' as its accepted taxon + child = self.roottaxon.children.create( + name="ChildTaxon", + acceptedtaxon=synonym, + definition=self.taxontreedef, + definitionitem=kingdom + ) + + # Verify initial state + self.assertEqual(child.acceptedtaxon.id, synonym.id) + + # Synonymize: set synonym's acceptedtaxon to accepted + synonym.acceptedtaxon = accepted + synonym.save() + + # Refresh from DB + child.refresh_from_db() + + # The child's acceptedtaxon should now point to the new accepted taxon + self.assertEqual(child.acceptedtaxon.id, accepted.id) + + def test_cannot_synonymize_to_synonymized_target(self): + """Test that setting acceptedtaxon to a node that is already a synonym raises an error""" + kingdom = self.roottaxontreedefitem.children.create( + name="Kingdom", + treedef=self.taxontreedef) + + # Create three taxa + taxon_a = self.roottaxon.children.create( + name="TaxonA", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + taxon_b = self.roottaxon.children.create( + name="TaxonB", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + taxon_c = self.roottaxon.children.create( + name="TaxonC", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + # Make taxon_b a synonym of taxon_a + taxon_b.acceptedtaxon = taxon_a + taxon_b.save() + + # Verify taxon_b is now a synonym + self.assertIsNotNone(taxon_b.acceptedtaxon_id) + + # Attempting to make taxon_c a synonym of taxon_b should fail + # because taxon_b is itself a synonym + from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException + taxon_c.acceptedtaxon = taxon_b + with self.assertRaises(TreeBusinessRuleException): + taxon_c.save() + + def test_cannot_synonymize_node_with_children(self): + """Test that setting acceptedtaxon on a node that has children raises an error""" + kingdom = self.roottaxontreedefitem.children.create( + name="Kingdom", + treedef=self.taxontreedef) + + # Create a parent taxon and a child taxon + parent = self.roottaxon.children.create( + name="Parent", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + child = self.roottaxon.children.create( + name="Child", + parent=parent, + definition=self.taxontreedef, + definitionitem=kingdom + ) + + accepted = self.roottaxon.children.create( + name="Accepted", + definition=self.taxontreedef, + definitionitem=kingdom + ) + + # Verify parent has children + self.assertEqual(parent.children.count(), 1) + + # Attempting to synonymize parent (which has children) should fail + from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException + parent.acceptedtaxon = accepted + with self.assertRaises(TreeBusinessRuleException): + parent.save() + + # Clean up child so parent can be deleted + child.delete() + parent.delete() + accepted.delete() diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index d8562fd1c60..cac67712188 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -6915,7 +6915,117 @@ class Meta: ] - save = partialmethod(custom_save) + def save(self, *args, **kwargs): + """Custom save method for Taxon that handles synonymization behavior + when the AcceptedID (Accepted/Preferred Taxon) changes. + + This ensures that when a Taxon's AcceptedID is changed (via API or form), + all related data is updated to reflect the new synonymization, matching + the behavior of the /api/specify_tree/taxon//synonymize/ endpoint. + + Handles two cases: + 1. Synonymization (AcceptedID is set to a new value): + - Validates the target is not itself a synonym + - Validates the source has no children + - Repoints acceptedchildren to the new accepted taxon + - Updates Determinations where this is the Taxon to point to the new AcceptedID + - Updates Determinations where this is the PreferredTaxon to point to the new AcceptedID + 2. Desynonymization (AcceptedID is cleared): + - Updates Determinations where PreferredTaxon is this Taxon to use themselves (F('taxon')) + + NOTE: This method intentionally duplicates some logic from extras.synonymize()/ + extras.desynonymize() because those functions call node.save() which triggers this + method, and then do the same updates again. This double-processing is harmless + (idempotent updates) and is accepted to ensure correctness regardless of the + code path used to change AcceptedID. + """ + # Get the old AcceptedID before we save (if this is an update) + old_accepted_id = None + if self.pk is not None: + try: + old_taxon = Taxon.objects.get(pk=self.pk) + old_accepted_id = old_taxon.acceptedtaxon_id + except Taxon.DoesNotExist: + pass + + # Call the parent save to handle timestamps and actually save the object + save_auto_timestamp_field_with_override(super(Taxon, self).save, args, kwargs, self) + + # Handle updates if AcceptedID changed + new_accepted_id = self.acceptedtaxon_id + + if old_accepted_id != new_accepted_id: + if new_accepted_id is not None: + # --- Synonymization: AcceptedID was set to a new value --- + + # Validation 1: Target must not already be a synonym + target = Taxon.objects.get(pk=new_accepted_id) + if target.acceptedtaxon_id is not None: + from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException + raise TreeBusinessRuleException( + f'Synonymizing "{self.fullname}" to synonymized node "{target.fullname}"', + {"tree": "Taxon", + "localizationKey": "nodeSynonymizeToSynonymized", + "node": { + "id": self.id, + "rankid": self.rankid, + "fullName": self.fullname, + "parentid": self.parent_id, + "children": list(self.children.values('id', 'fullname')) + }, + "synonymized": { + "id": target.id, + "rankid": target.rankid, + "fullName": target.fullname, + "parentid": target.parent_id, + "children": list(target.children.values('id', 'fullname')) + }}) + + # Validation 2: Source must not have children + if self.children.count() > 0: + from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException + raise TreeBusinessRuleException( + f'Synonymizing node "{self.fullname}" which has children', + {"tree": "Taxon", + "localizationKey": "nodeSynonimizeWithChildren", + "node": { + "id": self.id, + "rankid": self.rankid, + "fullName": self.fullname, + "children": list(self.children.values('id', 'fullname')) + }, + "parent": { + "id": target.id, + "rankid": target.rankid, + "fullName": target.fullname, + "parentid": target.parent_id, + "children": list(target.children.values('id', 'fullname')) + }}) + + # Repoint acceptedchildren to the new accepted taxon + self.acceptedchildren.update(acceptedtaxon=target) + + # Update Determinations where this Taxon is the determined taxon + self.determinations.update(preferredtaxon=target) + + # Update Determinations where this Taxon is already the preferred taxon + # Use lazy import to avoid circular import issues (same pattern as extras.py) + from specifyweb.specify.models import Determination + Determination.objects.filter(preferredtaxon_id=self.id).update( + preferredtaxon=target + ) + elif old_accepted_id is not None: + # --- Desynonymization: AcceptedID was cleared --- + # Update Determinations where this Taxon is the determined taxon + # to use themselves as the preferred taxon (F('taxon')) + self.determinations.update(preferredtaxon=models.F('taxon')) + + # Update Determinations where this Taxon is the preferred taxon + # to use themselves as the preferred taxon + from specifyweb.specify.models import Determination + Determination.objects.filter(preferredtaxon_id=self.id).update( + preferredtaxon=models.F('taxon') + ) class Taxonattachment(models.Model): specify_model = datamodel.get_table_strict('taxonattachment') From b0ca951bd48dfab27e5b6f1af072e8a8ed5a998f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 14 May 2026 23:17:10 -0500 Subject: [PATCH 2/2] fix(trees): fix test for child taxon creation --- specifyweb/backend/businessrules/tests/test_taxon.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/specifyweb/backend/businessrules/tests/test_taxon.py b/specifyweb/backend/businessrules/tests/test_taxon.py index 1b3b3b8a027..e9daffe3c53 100644 --- a/specifyweb/backend/businessrules/tests/test_taxon.py +++ b/specifyweb/backend/businessrules/tests/test_taxon.py @@ -371,9 +371,8 @@ def test_cannot_synonymize_node_with_children(self): definitionitem=kingdom ) - child = self.roottaxon.children.create( + child = parent.children.create( name="Child", - parent=parent, definition=self.taxontreedef, definitionitem=kingdom )