diff --git a/specifyweb/backend/businessrules/rules/collectionobject_rules.py b/specifyweb/backend/businessrules/rules/collectionobject_rules.py index 40ffec39a0c..fe76d7b4985 100644 --- a/specifyweb/backend/businessrules/rules/collectionobject_rules.py +++ b/specifyweb/backend/businessrules/rules/collectionobject_rules.py @@ -2,6 +2,7 @@ from specifyweb.backend.businessrules.exceptions import BusinessRuleException from specifyweb.backend.businessrules.utils import get_unique_catnum_across_comp_co_coll_pref +from specifyweb.specify.api.utils import ensure_collection_object_type from specifyweb.specify.models import Component @@ -10,8 +11,7 @@ def collectionobject_pre_save(co): if co.collectionmemberid is None: co.collectionmemberid = co.collection_id - if co.collectionobjecttype is None: - co.collectionobjecttype = co.collection.collectionobjecttype + ensure_collection_object_type(co, using=co._state.db or 'default') agent = co.createdbyagent if agent is not None and agent.specifyuser is not None: @@ -25,4 +25,4 @@ def collectionobject_pre_save(co): if contains_component_duplicates: raise BusinessRuleException( - 'Catalog Number is already in use for another Component in this collection.') \ No newline at end of file + 'Catalog Number is already in use for another Component in this collection.') diff --git a/specifyweb/backend/businessrules/rules/determination_rules.py b/specifyweb/backend/businessrules/rules/determination_rules.py index ef93a8210d2..e9bb29ef341 100644 --- a/specifyweb/backend/businessrules/rules/determination_rules.py +++ b/specifyweb/backend/businessrules/rules/determination_rules.py @@ -1,5 +1,6 @@ from specifyweb.backend.businessrules.orm_signal_handler import orm_signal_handler +from specifyweb.specify.api.utils import ensure_collection_object_type from specifyweb.specify.models import Determination, Taxon @@ -8,6 +9,12 @@ def determination_pre_save(det): if det.collectionmemberid is None: det.collectionmemberid = det.collectionobject.collectionmemberid + ensure_collection_object_type( + det.collectionobject, + using=det._state.db or 'default', + persist=True, + ) + taxon_id = det.taxon_id if taxon_id is None: det.preferredtaxon = None @@ -33,4 +40,4 @@ def determination_pre_save(det): def only_one_determination_iscurrent(determination): if determination.iscurrent: Determination.objects.filter( - collectionobject=determination.collectionobject_id).update(iscurrent=False) \ No newline at end of file + collectionobject=determination.collectionobject_id).update(iscurrent=False) diff --git a/specifyweb/backend/businessrules/tests/test_collectionobject.py b/specifyweb/backend/businessrules/tests/test_collectionobject.py index 2428a1f537b..4f7a0ca739c 100644 --- a/specifyweb/backend/businessrules/tests/test_collectionobject.py +++ b/specifyweb/backend/businessrules/tests/test_collectionobject.py @@ -29,3 +29,120 @@ def test_default_collectionobjecttype(self): self.assertIsNotNone(test_co.collectionobjecttype) self.assertEqual(test_co.collectionobjecttype, default_type) + + def test_new_collectionobject_without_collection_default_keeps_null_type(self): + test_co = Collectionobject.objects.create( + collection=self.collection + ) + + self.assertIsNone(test_co.collectionobjecttype) + + def test_existing_collectionobject_without_type_uses_collection_default_on_save(self): + default_type = Collectionobjecttype.objects.create( + name="default type", + collection=self.collection, + taxontreedef=self.discipline.taxontreedef + ) + self.collection.collectionobjecttype = default_type + self.collection.save() + + test_co = self.collectionobjects[0] + Collectionobject.objects.filter(pk=test_co.pk).update( + collectionobjecttype=None + ) + + test_co.refresh_from_db() + self.assertIsNone(test_co.collectionobjecttype) + + test_co.save() + test_co.refresh_from_db() + + self.assertEqual(test_co.collectionobjecttype, default_type) + + def test_existing_collectionobject_without_type_creates_collection_default_on_save(self): + self.discipline.name = "Fallback Discipline" + self.discipline.save() + + test_co = self.collectionobjects[0] + Collectionobject.objects.filter(pk=test_co.pk).update( + collectionobjecttype=None + ) + + test_co.refresh_from_db() + self.assertIsNone(test_co.collectionobjecttype) + self.assertIsNone(self.collection.collectionobjecttype) + + test_co.save() + test_co.refresh_from_db() + self.collection.refresh_from_db() + + self.assertIsNotNone(self.collection.collectionobjecttype) + self.assertEqual( + self.collection.collectionobjecttype, + test_co.collectionobjecttype + ) + self.assertEqual( + test_co.collectionobjecttype.name, + "Fallback Discipline" + ) + self.assertEqual( + test_co.collectionobjecttype.taxontreedef, + self.discipline.taxontreedef + ) + + def test_saving_determination_populates_existing_collectionobject_type(self): + self.discipline.name = "Fallback Discipline" + self.discipline.save() + + test_co = self.collectionobjects[0] + Collection.objects.filter(pk=self.collection.pk).update( + collectionobjecttype=None + ) + Collectionobject.objects.filter(pk=test_co.pk).update( + collectionobjecttype=None + ) + + test_co.refresh_from_db() + self.collection.refresh_from_db() + self.assertIsNone(test_co.collectionobjecttype) + self.assertIsNone(self.collection.collectionobjecttype) + + test_co.determinations.create(remarks="new determination") + test_co.refresh_from_db() + self.collection.refresh_from_db() + + self.assertIsNotNone(test_co.collectionobjecttype) + self.assertEqual( + self.collection.collectionobjecttype, + test_co.collectionobjecttype + ) + self.assertEqual( + test_co.collectionobjecttype.name, + "Fallback Discipline" + ) + self.assertEqual( + test_co.collectionobjecttype.taxontreedef, + self.discipline.taxontreedef + ) + + def test_saving_determination_uses_collection_default_type(self): + default_type = Collectionobjecttype.objects.create( + name="default type", + collection=self.collection, + taxontreedef=self.discipline.taxontreedef + ) + self.collection.collectionobjecttype = default_type + self.collection.save() + + test_co = self.collectionobjects[0] + Collectionobject.objects.filter(pk=test_co.pk).update( + collectionobjecttype=None + ) + + test_co.refresh_from_db() + self.assertIsNone(test_co.collectionobjecttype) + + test_co.determinations.create(remarks="new determination") + test_co.refresh_from_db() + + self.assertEqual(test_co.collectionobjecttype, default_type) diff --git a/specifyweb/backend/stored_queries/tests/tests_legacy.py b/specifyweb/backend/stored_queries/tests/tests_legacy.py index cb34b27ae22..1090b60f30b 100644 --- a/specifyweb/backend/stored_queries/tests/tests_legacy.py +++ b/specifyweb/backend/stored_queries/tests/tests_legacy.py @@ -841,9 +841,6 @@ def test_sqlalchemy_model_errors(self): ] }, "CollectionObject": { - "not_found": [ - "projects" - ], "incorrect_direction": { "cojo": [ "onetomany", diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 26226f396f9..1ef80c1e81d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -194,6 +194,64 @@ describe('Collection Object business rules', () => { expect(result.current[0]).toStrictEqual([]); }); + test('CollectionObject -> determinations: Save is not blocked when collection object type is missing', async () => { + const collectionObject = getBaseCollectionObject(); + collectionObject.set('collectionObjectType', null as never, { + silent: true, + }); + + const determination = + collectionObject.getDependentResource('determinations')?.models[0]; + + const { result } = renderHook(() => + useSaveBlockers(determination, tables.Determination.getField('Taxon')) + ); + + await act(async () => { + await collectionObject?.businessRuleManager?.checkField( + 'collectionObjectType' + ); + }); + + expect(result.current[0]).toStrictEqual([]); + }); + + test('CollectionObject -> determinations: Missing collection object type clears invalid determination blockers', async () => { + const collectionObject = getBaseCollectionObject(); + collectionObject.set( + 'collectionObjectType', + getResourceApiUrl('CollectionObjectType', 1) + ); + + const determination = + collectionObject.getDependentResource('determinations')?.models[0]; + + const { result } = renderHook(() => + useSaveBlockers(determination, tables.Determination.getField('Taxon')) + ); + + await act(async () => { + await collectionObject?.businessRuleManager?.checkField( + 'collectionObjectType' + ); + }); + expect(result.current[0]).toStrictEqual([ + resourcesText.invalidDeterminationTaxon(), + ]); + + collectionObject.set('collectionObjectType', null as never, { + silent: true, + }); + + await act(async () => { + await collectionObject?.businessRuleManager?.checkField( + 'collectionObjectType' + ); + }); + + expect(result.current[0]).toStrictEqual([]); + }); + test('Newly added determinations are current by default', async () => { const collectionObject = getBaseCollectionObject(); const determinations = diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index d8921c1df49..5312555800e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -181,6 +181,19 @@ export const businessRuleDefs: MappedBusinessRuleDefs = { determinations.models.map(async (det) => det.rgetPromise('taxon')) ); const coType = await resource.rgetPromise('collectionObjectType'); + + if (coType === null) { + determinations.models.forEach((determination) => { + setSaveBlockers( + determination, + determination.specifyTable.field.taxon, + [], + DETERMINATION_TAXON_KEY + ); + }); + return; + } + const coTypeTreeDef = coType.get('taxonTreeDef'); // Block save when a Determination -> Taxon does not belong to the COType's tree definition diff --git a/specifyweb/specify/api/utils.py b/specifyweb/specify/api/utils.py index 1b53ff42bd7..98d5f4c082f 100644 --- a/specifyweb/specify/api/utils.py +++ b/specifyweb/specify/api/utils.py @@ -29,6 +29,78 @@ def log_sqlalchemy_query(query): # Run in the storred_queries.execute file, in the execute function, right before the return statement, line 546 # from specifyweb.specify.utils import log_sqlalchemy_query; log_sqlalchemy_query(query) +def get_or_create_default_collection_object_type(collection: spmodels.Collection, using: str = "default"): + db = using or "default" + + collectionobjecttype = collection.collectionobjecttype + + if collectionobjecttype is not None: + return collectionobjecttype + + discipline_name = collection.discipline.name + taxon_tree_def_id = collection.discipline.taxontreedef_id + + if discipline_name is None or taxon_tree_def_id is None: + logger.warning( + "Cannot create default Collectionobjecttype for collection %s: " + "discipline_name=%r, discipline.taxontreedef_id=%r, " + "collectionobjecttype=%r.", + collection.pk, + discipline_name, + taxon_tree_def_id, + collectionobjecttype, + ) + return None + + default_type, _ = spmodels.Collectionobjecttype.objects.using(db).get_or_create( + name=discipline_name, + collection=collection, + taxontreedef_id=taxon_tree_def_id, + ) + + type(collection).objects.using(db).filter( + pk=collection.pk, + collectionobjecttype__isnull=True, + ).update(collectionobjecttype=default_type) + collection.collectionobjecttype = default_type + + return default_type + +def ensure_collection_object_type( + collection_object: spmodels.Collectionobject, + using: str = "default", + persist: bool = False, +): + db = using or "default" + + if collection_object.collectionobjecttype is not None: + return collection_object.collectionobjecttype + + if collection_object.collection.collectionobjecttype is not None: + collection_object.collectionobjecttype = ( + collection_object.collection.collectionobjecttype + ) + elif collection_object.pk is not None: + collection_object.collectionobjecttype = ( + get_or_create_default_collection_object_type( + collection_object.collection, using=db + ) + ) + + if ( + persist + and collection_object.pk is not None + and collection_object.collectionobjecttype is not None + ): + type(collection_object).objects.using(db).filter( + pk=collection_object.pk, + collectionobjecttype__isnull=True, + ).update( + collectionobjecttype_id=collection_object.collectionobjecttype_id + ) + + return collection_object.collectionobjecttype + def create_default_collection_types(apps, using="default"): db = using or "default" @@ -90,4 +162,4 @@ def get_picklists(collection: spmodels.Collection, tablename: str, fieldname: st if len(collection_picklists) > 0: picklists = collection_picklists - return picklists, schemaitem \ No newline at end of file + return picklists, schemaitem