Skip to content
Draft
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
201 changes: 201 additions & 0 deletions specifyweb/backend/businessrules/tests/test_taxon.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,204 @@ 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 = parent.children.create(
name="Child",
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()
112 changes: 111 additions & 1 deletion specifyweb/specify/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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:

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.
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

Check notice

Code scanning / CodeQL

Module imports itself Note

The module 'specifyweb.specify.models' imports itself.
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

Check notice

Code scanning / CodeQL

Module imports itself Note

The module 'specifyweb.specify.models' imports itself.
Determination.objects.filter(preferredtaxon_id=self.id).update(
preferredtaxon=models.F('taxon')
)

class Taxonattachment(models.Model):
specify_model = datamodel.get_table_strict('taxonattachment')
Expand Down
Loading