From ecbdbf94078c1ece4dc2adbf445d3733df247a0a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 16 Jan 2026 16:22:21 -0600 Subject: [PATCH 01/40] main unit test fixes --- .../tests/test_localityupdate_status.py | 10 ++--- .../tests/test_parse_field.py | 2 +- .../locality_update_tool/update_locality.py | 16 +++++-- specifyweb/backend/trees/extras.py | 42 ++++++++++++++++--- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/specifyweb/backend/locality_update_tool/tests/test_localityupdate_status.py b/specifyweb/backend/locality_update_tool/tests/test_localityupdate_status.py index acad98e50a6..5b6fceb161f 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_localityupdate_status.py +++ b/specifyweb/backend/locality_update_tool/tests/test_localityupdate_status.py @@ -36,7 +36,7 @@ def test_localityupdate_not_exist(self): self._assertStatusCodeEqual(response, http.HttpResponseNotFound.status_code) self.assertEqual(response.content.decode(), f"The localityupdate with task id '{task_id}' was not found") - @patch("specifyweb.specify.views.update_locality_task.AsyncResult") + @patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult") def test_failed(self, AsyncResult: Mock): mock_result = Mock() mock_result.state = CELERY_TASK_STATE.FAILURE @@ -70,7 +70,7 @@ def test_failed(self, AsyncResult: Mock): } ) - @patch("specifyweb.specify.views.update_locality_task.AsyncResult") + @patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult") def test_parse_failed(self, AsyncResult: Mock): mock_result = Mock() mock_result.state = CELERY_TASK_STATE.SUCCESS @@ -98,7 +98,7 @@ def test_parse_failed(self, AsyncResult: Mock): } ) - @patch("specifyweb.specify.views.update_locality_task.AsyncResult") + @patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult") def test_parsed(self, AsyncResult: Mock): mock_result = Mock() mock_result.state = CELERY_TASK_STATE.SUCCESS @@ -149,7 +149,7 @@ def test_parsed(self, AsyncResult: Mock): } ) - @patch("specifyweb.specify.views.update_locality_task.AsyncResult") + @patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult") def test_succeeded(self, AsyncResult: Mock): mock_result = Mock() mock_result.state = LocalityUpdateStatus.SUCCEEDED @@ -181,7 +181,7 @@ def test_succeeded(self, AsyncResult: Mock): } ) - @patch("specifyweb.specify.views.update_locality_task.AsyncResult") + @patch("specifyweb.backend.locality_update_tool.views.update_locality_task.AsyncResult") def test_succeeded_locality_rows(self, AsyncResult: Mock): mock_result = Mock() mock_result.state = LocalityUpdateStatus.SUCCEEDED diff --git a/specifyweb/backend/locality_update_tool/tests/test_parse_field.py b/specifyweb/backend/locality_update_tool/tests/test_parse_field.py index 02e7f447433..3e512bb41df 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_parse_field.py +++ b/specifyweb/backend/locality_update_tool/tests/test_parse_field.py @@ -33,7 +33,7 @@ def test_no_ui_formatter(self): self.assertEqual(parsed_with_value, parsed_with_value_result) - @patch("specifyweb.specify.update_locality.get_uiformatter") + @patch("specifyweb.backend.locality_update_tool.update_locality.get_uiformatter") def test_cnn_formatter(self, get_uiformatter: Mock): get_uiformatter.return_value = UIFormatter( diff --git a/specifyweb/backend/locality_update_tool/update_locality.py b/specifyweb/backend/locality_update_tool/update_locality.py index ae1873f1631..1fe2985daf8 100644 --- a/specifyweb/backend/locality_update_tool/update_locality.py +++ b/specifyweb/backend/locality_update_tool/update_locality.py @@ -379,11 +379,19 @@ def parse_locality_set(collection, raw_headers: list[str], data: list[list[str]] locality_id: int | None = None if len( locality_query) != 1 else locality_query[0].id - parsed_locality_fields = [parse_field( - collection, 'Locality', dict['field'], dict['value'], locality_id, row_number) for dict in locality_values if dict['value'].strip() != ""] + parsed_locality_fields = [ + parse_field( + collection, 'Locality', d['field'], d['value'], locality_id, row_number + ) + for d in locality_values + ] - parsed_geocoorddetail_fields = [parse_field( - collection, 'Geocoorddetail', dict["field"], dict['value'], locality_id, row_number) for dict in geocoorddetail_values if dict['value'].strip() != ""] + parsed_geocoorddetail_fields = [ + parse_field( + collection, 'Geocoorddetail', d['field'], d['value'], locality_id, row_number + ) + for d in geocoorddetail_values + ] parsed_row, parsed_errors = merge_parse_results( [*parsed_locality_fields, *parsed_geocoorddetail_fields], locality_id, row_number) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 52d16aec7d2..ace1cdf1e3e 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -411,18 +411,48 @@ def synonymize(node, into, agent, user=None, collection=None): # This check can be disabled by a remote pref import specifyweb.backend.context.app_resource as app_resource - collection_prefs_json, _, __ = app_resource.get_app_resource(collection, user, 'CollectionPreferences') - if collection_prefs_json is not None: - collection_prefs_dict = json.loads(collection_prefs_json) - treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) + collection_prefs_dict = {} # always defined + res = app_resource.get_app_resource(collection, user, 'CollectionPreferences') + force_checks = (collection is None or user is None) + if res is not None: + collection_prefs_json, _, __ = res + if collection_prefs_json: + try: + collection_prefs_dict = json.loads(collection_prefs_json) or {} + except Exception: + collection_prefs_dict = {} + + import specifyweb.backend.context.app_resource as app_resource + + treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) + if force_checks and target.children.exists(): + raise TreeBusinessRuleException( + f'Synonymizing "{node.fullname}" to "{into.fullname}" which has children', + {"tree": "Taxon", + "localizationKey": "nodeSynonimizeWithChildren", + "node": { + "id": node.id, + "rankid": node.rankid, + "fullName": node.fullname, + "children": list(node.children.values('id', 'fullname')) + }, + "parent": { + "id": into.id, + "rankid": into.rankid, + "fullName": into.fullname, + "parentid": into.parent.id, + "children": list(into.children.values('id', 'fullname')) + }} + ) + force_checks = (collection is None or user is None) synonymized = treeManagement_pref.get('synonymized', {}) \ if isinstance(treeManagement_pref, dict) else {} add_synonym_enabled = synonymized.get(r'^sp7\.allow_adding_child_to_synonymized_parent\.' + node.specify_model.name + '=(.+)', False) if isinstance(synonymized, dict) else False - if node.children.count() > 0 and (add_synonym_enabled is True): + if node.children.count() > 0 and (force_checks or add_synonym_enabled is False): raise TreeBusinessRuleException( f'Synonymizing node "{node.fullname}" which has children', {"tree" : "Taxon", @@ -840,4 +870,4 @@ def tree_path_expr(tbl: str, d: int) -> str: # replace path_expr if ordering iss from specifyweb.specify.models import datamodel, Sptasksemaphore tree_model = datamodel.get_table(table) tasknames = [name.format(tree_model.name) for name in ("UpdateNodes{}", "BadNodes{}")] - Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) + Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) \ No newline at end of file From 45f696021c0013c544b284d29cb4a00d3b62bb0a Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 2 Feb 2026 11:29:41 -0600 Subject: [PATCH 02/40] fix: use correct key when accessing tree collection preference --- specifyweb/backend/trees/extras.py | 58 +++++++----------------------- 1 file changed, 13 insertions(+), 45 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index ace1cdf1e3e..d236a9d50ea 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -215,17 +215,16 @@ def adding_node(node,collection=None, user=None): if parent.accepted_id is not None: collection_prefs_json, _, __ = app_resource.get_app_resource(collection, user, 'CollectionPreferences') - if collection_prefs_json is not None: - collection_prefs_dict = json.loads(collection_prefs_json) + collection_prefs_dict = (json.loads(collection_prefs_json) + if collection_prefs_json is not None + else dict()) treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) + synonymized = treeManagement_pref.get('synonymized', dict()) - synonymized = treeManagement_pref.get('synonymized', {}) \ - if isinstance(treeManagement_pref, dict) else {} + add_synonym_enabled = synonymized.get('sp7.allow_adding_child_to_synonymized_parent.' + node.specify_model.name, False) if isinstance(synonymized, dict) else False - add_synonym_enabled = synonymized.get(r'^sp7\.allow_adding_child_to_synonymized_parent\.' + node.specify_model.name + '=(.+)', False) if isinstance(synonymized, dict) else False - - if add_synonym_enabled is True: + if add_synonym_enabled is False: raise TreeBusinessRuleException( f'Adding node "{node.fullname}" to synonymized parent "{parent.fullname}"', {"tree" : "Taxon", @@ -411,48 +410,17 @@ def synonymize(node, into, agent, user=None, collection=None): # This check can be disabled by a remote pref import specifyweb.backend.context.app_resource as app_resource - - collection_prefs_dict = {} # always defined - - res = app_resource.get_app_resource(collection, user, 'CollectionPreferences') - force_checks = (collection is None or user is None) - if res is not None: - collection_prefs_json, _, __ = res - if collection_prefs_json: - try: - collection_prefs_dict = json.loads(collection_prefs_json) or {} - except Exception: - collection_prefs_dict = {} - - import specifyweb.backend.context.app_resource as app_resource + collection_prefs_json, _, __ = app_resource.get_app_resource(collection, user, 'CollectionPreferences') + collection_prefs_dict = (json.loads(collection_prefs_json) + if collection_prefs_json is not None + else dict()) treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) - if force_checks and target.children.exists(): - raise TreeBusinessRuleException( - f'Synonymizing "{node.fullname}" to "{into.fullname}" which has children', - {"tree": "Taxon", - "localizationKey": "nodeSynonimizeWithChildren", - "node": { - "id": node.id, - "rankid": node.rankid, - "fullName": node.fullname, - "children": list(node.children.values('id', 'fullname')) - }, - "parent": { - "id": into.id, - "rankid": into.rankid, - "fullName": into.fullname, - "parentid": into.parent.id, - "children": list(into.children.values('id', 'fullname')) - }} - ) - force_checks = (collection is None or user is None) - synonymized = treeManagement_pref.get('synonymized', {}) \ - if isinstance(treeManagement_pref, dict) else {} + synonymized = treeManagement_pref.get('synonymized', dict()) - add_synonym_enabled = synonymized.get(r'^sp7\.allow_adding_child_to_synonymized_parent\.' + node.specify_model.name + '=(.+)', False) if isinstance(synonymized, dict) else False + add_synonym_enabled = synonymized.get('sp7.allow_adding_child_to_synonymized_parent.' + node.specify_model.name, False) if isinstance(synonymized, dict) else False - if node.children.count() > 0 and (force_checks or add_synonym_enabled is False): + if (add_synonym_enabled == False) and node.children.count() > 0: raise TreeBusinessRuleException( f'Synonymizing node "{node.fullname}" which has children', {"tree" : "Taxon", From af744726714ca9d06b6eff064b334f95506e4368 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 2 Feb 2026 15:56:20 -0600 Subject: [PATCH 03/40] add _get_collection_prefs_dict helper function --- specifyweb/backend/trees/extras.py | 56 +++++++++++++++++++----------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index d236a9d50ea..d94833729f8 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -207,23 +207,38 @@ def close_interval(model, node_number, size): highestchildnodenumber=F('highestchildnodenumber')-size, ) -def adding_node(node,collection=None, user=None): +def _get_collection_prefs_dict(collection, user) -> dict: import specifyweb.backend.context.app_resource as app_resource + res = app_resource.get_app_resource(collection, user, 'CollectionPreferences') + if not res: + return {} + collection_prefs_json, _, __ = res + if not collection_prefs_json: + return {} + try: + loaded = json.loads(collection_prefs_json) + return loaded if isinstance(loaded, dict) else {} + except Exception: + return {} + +def adding_node(node, collection=None, user=None): logger.info('adding node %s', node) model = type(node) parent = model.objects.select_for_update().get(id=node.parent.id) if parent.accepted_id is not None: - collection_prefs_json, _, __ = app_resource.get_app_resource(collection, user, 'CollectionPreferences') - collection_prefs_dict = (json.loads(collection_prefs_json) - if collection_prefs_json is not None - else dict()) + collection_prefs_dict = _get_collection_prefs_dict(collection, user) treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) - synonymized = treeManagement_pref.get('synonymized', dict()) + treeManagement_pref = treeManagement_pref if isinstance(treeManagement_pref, dict) else {} + + synonymized = treeManagement_pref.get('synonymized', {}) + synonymized = synonymized if isinstance(synonymized, dict) else {} - add_synonym_enabled = synonymized.get('sp7.allow_adding_child_to_synonymized_parent.' + node.specify_model.name, False) if isinstance(synonymized, dict) else False + pref_key = f"sp7.allow_adding_child_to_synonymized_parent.{node.specify_model.name}" + add_synonym_enabled = bool(synonymized.get(pref_key, False)) + # If NOT enabled, block adding to synonymized parent if add_synonym_enabled is False: raise TreeBusinessRuleException( f'Adding node "{node.fullname}" to synonymized parent "{parent.fullname}"', @@ -244,7 +259,6 @@ def adding_node(node,collection=None, user=None): "parentid": parent.parent.id, "children": list(parent.children.values('id', 'fullname')) }}) - insertion_point = open_interval(model, parent.nodenumber, 1) node.highestchildnodenumber = node.nodenumber = insertion_point @@ -409,35 +423,35 @@ def synonymize(node, into, agent, user=None, collection=None): node.save() # This check can be disabled by a remote pref - import specifyweb.backend.context.app_resource as app_resource - collection_prefs_json, _, __ = app_resource.get_app_resource(collection, user, 'CollectionPreferences') - collection_prefs_dict = (json.loads(collection_prefs_json) - if collection_prefs_json is not None - else dict()) + collection_prefs_dict = _get_collection_prefs_dict(collection, user) treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) - synonymized = treeManagement_pref.get('synonymized', dict()) + treeManagement_pref = treeManagement_pref if isinstance(treeManagement_pref, dict) else {} + + synonymized = treeManagement_pref.get('synonymized', {}) + synonymized = synonymized if isinstance(synonymized, dict) else {} - add_synonym_enabled = synonymized.get('sp7.allow_adding_child_to_synonymized_parent.' + node.specify_model.name, False) if isinstance(synonymized, dict) else False + pref_key = f"sp7.allow_adding_child_to_synonymized_parent.{node.specify_model.name}" + add_synonym_enabled = bool(synonymized.get(pref_key, False)) - if (add_synonym_enabled == False) and node.children.count() > 0: + if (add_synonym_enabled is False) and node.children.exists(): raise TreeBusinessRuleException( f'Synonymizing node "{node.fullname}" which has children', {"tree" : "Taxon", - "localizationKey" : "nodeSynonimizeWithChildren", - "node" : { + "localizationKey" : "nodeSynonimizeWithChildren", + "node" : { "id" : node.id, "rankid" : node.rankid, "fullName" : node.fullname, "children": list(node.children.values('id', 'fullname')) - }, - "parent" : { + }, + "parent" : { "id" : into.id, "rankid" : into.rankid, "fullName" : into.fullname, "parentid": into.parent.id, "children": list(into.children.values('id', 'fullname')) - }}) + }}) node.acceptedchildren.update(**{node.accepted_id_attr().replace('_id', ''): target}) #assuming synonym can't be synonymized field_change_infos = [ From c7ed2488add754c0345abd8a97e470fd9e7c30ff Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 3 Feb 2026 09:28:54 -0600 Subject: [PATCH 04/40] trigger test --- specifyweb/backend/trees/extras.py | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index d94833729f8..564693045c3 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -238,7 +238,6 @@ def adding_node(node, collection=None, user=None): pref_key = f"sp7.allow_adding_child_to_synonymized_parent.{node.specify_model.name}" add_synonym_enabled = bool(synonymized.get(pref_key, False)) - # If NOT enabled, block adding to synonymized parent if add_synonym_enabled is False: raise TreeBusinessRuleException( f'Adding node "{node.fullname}" to synonymized parent "{parent.fullname}"', From 6b372eea4f1695d730e3a3eeb7aad789ac333f5e Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 3 Feb 2026 10:00:47 -0600 Subject: [PATCH 05/40] add _expand_synonymization_actions_enabled --- specifyweb/backend/trees/extras.py | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 564693045c3..27a3342dc2e 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -221,6 +221,35 @@ def _get_collection_prefs_dict(collection, user) -> dict: except Exception: return {} +def _expand_synonymization_actions_enabled(collection, user, tree_name: str) -> bool: + """ + New CollectionPreferences shape: + treeManagement.expand_synonymization_actions. = true/false + + Backward compat with legacy shape: + treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false + """ + prefs = _get_collection_prefs_dict(collection, user) + + tm = prefs.get("treeManagement") or {} + if not isinstance(tm, dict): + return False + + # New shape + esa = tm.get("expand_synonymization_actions") + if isinstance(esa, dict) and tree_name in esa: + return bool(esa.get(tree_name)) + + # Legacy shape + syn = tm.get("synonymized") + if isinstance(syn, dict): + legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{tree_name}" + if legacy_key in syn: + return bool(syn.get(legacy_key)) + + # Default if nothing set + return False + def adding_node(node, collection=None, user=None): logger.info('adding node %s', node) model = type(node) @@ -235,8 +264,8 @@ def adding_node(node, collection=None, user=None): synonymized = treeManagement_pref.get('synonymized', {}) synonymized = synonymized if isinstance(synonymized, dict) else {} - pref_key = f"sp7.allow_adding_child_to_synonymized_parent.{node.specify_model.name}" - add_synonym_enabled = bool(synonymized.get(pref_key, False)) + tree_name = node.specify_model.name + add_synonym_enabled = _expand_synonymization_actions_enabled(collection, user, tree_name) if add_synonym_enabled is False: raise TreeBusinessRuleException( From b87f4da4f60977ed53f2353162ef41bc9dd29cfe Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 6 Feb 2026 09:47:22 -0600 Subject: [PATCH 06/40] fix: broken imports in locality update tests --- specifyweb/backend/locality_update_tool/__init__.py | 0 .../tests/test_create_localityupdate_recordset.py | 2 +- .../locality_update_tool/tests/test_merge_parse_results.py | 2 +- .../locality_update_tool/tests/test_parse_locality_set.py | 2 +- .../tests/test_parse_locality_set_foreground.py | 2 +- .../tests/test_resolve_localityupdate_result.py | 2 +- .../tests/test_start_locality_set_background.py | 2 +- .../locality_update_tool/tests/test_upload_from_parsed.py | 2 +- .../locality_update_tool/tests/test_upload_locality_set.py | 3 +-- .../tests/test_upload_locality_set_foreground.py | 2 +- 10 files changed, 9 insertions(+), 10 deletions(-) create mode 100644 specifyweb/backend/locality_update_tool/__init__.py diff --git a/specifyweb/backend/locality_update_tool/__init__.py b/specifyweb/backend/locality_update_tool/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/specifyweb/backend/locality_update_tool/tests/test_create_localityupdate_recordset.py b/specifyweb/backend/locality_update_tool/tests/test_create_localityupdate_recordset.py index 533edf8d33f..450adc6c0c9 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_create_localityupdate_recordset.py +++ b/specifyweb/backend/locality_update_tool/tests/test_create_localityupdate_recordset.py @@ -1,5 +1,5 @@ from specifyweb.specify.models import Recordset, Recordsetitem -from specifyweb.specify.tests.test_update_locality.test_update_locality_context import ( +from specifyweb.backend.locality_update_tool.tests.test_update_locality_context import ( TestUpdateLocalityContext, ) from specifyweb.backend.locality_update_tool.update_locality import create_localityupdate_recordset diff --git a/specifyweb/backend/locality_update_tool/tests/test_merge_parse_results.py b/specifyweb/backend/locality_update_tool/tests/test_merge_parse_results.py index 1b0974e4053..f9d67bd5bea 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_merge_parse_results.py +++ b/specifyweb/backend/locality_update_tool/tests/test_merge_parse_results.py @@ -1,4 +1,4 @@ -from specifyweb.specify.tests.test_update_locality.test_update_locality_context import ( +from specifyweb.backend.locality_update_tool.tests.test_update_locality_context import ( TestUpdateLocalityContext, ) from specifyweb.backend.locality_update_tool.update_locality import ( diff --git a/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set.py b/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set.py index 07aac55f344..14e8b73862b 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set.py +++ b/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set.py @@ -1,4 +1,4 @@ -from specifyweb.specify.tests.test_update_locality.test_update_locality_context import ( +from specifyweb.backend.locality_update_tool.tests.test_update_locality_context import ( TestUpdateLocalityContext, ) from specifyweb.backend.locality_update_tool.update_locality import ( diff --git a/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py b/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py index e0ce5ff04b5..0e0bdc92d2e 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py +++ b/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py @@ -1,7 +1,7 @@ from unittest.mock import patch from specifyweb.specify.tests.test_api import ApiTests from specifyweb.backend.locality_update_tool.update_locality import ParseError, ParsedRow -from specifyweb.specify.views import parse_locality_set_foreground +from specifyweb.backend.locality_update_tool.views import parse_locality_set_foreground class TestParseLocalitySetForeground(ApiTests): diff --git a/specifyweb/backend/locality_update_tool/tests/test_resolve_localityupdate_result.py b/specifyweb/backend/locality_update_tool/tests/test_resolve_localityupdate_result.py index eddcd8c5d65..53dacfcd577 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_resolve_localityupdate_result.py +++ b/specifyweb/backend/locality_update_tool/tests/test_resolve_localityupdate_result.py @@ -2,7 +2,7 @@ from uuid import uuid4 -from specifyweb.specify.tests.test_update_locality.test_update_locality_context import ( +from specifyweb.backend.locality_update_tool.tests.test_update_locality_context import ( TestUpdateLocalityContext, ) from specifyweb.backend.locality_update_tool.update_locality import ( diff --git a/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py b/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py index d8a5e85efb2..bd19dde3106 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py +++ b/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py @@ -4,7 +4,7 @@ from uuid import uuid4 as base_uuid4 from specifyweb.backend.locality_update_tool.update_locality import LocalityUpdateStatus -from specifyweb.specify.views import start_locality_set_background +from specifyweb.backend.locality_update_tool.views import start_locality_set_background class TestStartLocalityBackground(ApiTests): diff --git a/specifyweb/backend/locality_update_tool/tests/test_upload_from_parsed.py b/specifyweb/backend/locality_update_tool/tests/test_upload_from_parsed.py index c0fb21bc35f..78957cdf319 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_upload_from_parsed.py +++ b/specifyweb/backend/locality_update_tool/tests/test_upload_from_parsed.py @@ -1,5 +1,5 @@ from unittest.mock import Mock, call -from specifyweb.specify.tests.test_update_locality.test_update_locality_context import ( +from specifyweb.backend.locality_update_tool.tests.test_update_locality_context import ( TestUpdateLocalityContext, ) from specifyweb.backend.locality_update_tool.update_locality import ( diff --git a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py index 586eaf07b8f..950525d05ef 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py +++ b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py @@ -1,7 +1,6 @@ -from specifyweb.specify.tests.test_update_locality.test_update_locality_context import ( +from specifyweb.backend.locality_update_tool.tests.test_update_locality_context import ( TestUpdateLocalityContext, ) -from specifyweb.backend.locality_update_tool.update_locality import upload_locality_set class TestUploadLocalitySet(TestUpdateLocalityContext): diff --git a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py index 55e4f700f2b..9476b9c7f4a 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py +++ b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py @@ -1,6 +1,6 @@ from unittest.mock import Mock, patch from specifyweb.specify.tests.test_api import ApiTests -from specifyweb.specify.views import upload_locality_set_foreground +from specifyweb.backend.locality_update_tool.views import upload_locality_set_foreground def get_success_result(*args, **kwargs): success_result = dict(type="Uploaded", results=[]) From 9e2bd1160caa6769862e5d72afb2680841088a97 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 12:30:24 -0600 Subject: [PATCH 07/40] raise error in exception handling --- specifyweb/backend/trees/extras.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 27a3342dc2e..02c249f373c 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -207,19 +207,40 @@ def close_interval(model, node_number, size): highestchildnodenumber=F('highestchildnodenumber')-size, ) -def _get_collection_prefs_dict(collection, user) -> dict: +def _get_collection_prefs_dict(collection, user, *, fail_silently: bool = False) -> dict: import specifyweb.backend.context.app_resource as app_resource + res = app_resource.get_app_resource(collection, user, 'CollectionPreferences') if not res: return {} + collection_prefs_json, _, __ = res if not collection_prefs_json: return {} + try: loaded = json.loads(collection_prefs_json) - return loaded if isinstance(loaded, dict) else {} + except json.JSONDecodeError as e: + logger.warning( + "Failed to parse CollectionPreferences JSON; collection_id=%s user_id=%s error=%s", + getattr(collection, "id", None), + getattr(user, "id", None), + str(e), + ) + if fail_silently: + return {} + raise except Exception: - return {} + logger.exception( + "Unexpected error while loading CollectionPreferences; collection_id=%s user_id=%s", + getattr(collection, "id", None), + getattr(user, "id", None), + ) + if fail_silently: + return {} + raise + + return loaded if isinstance(loaded, dict) else {} def _expand_synonymization_actions_enabled(collection, user, tree_name: str) -> bool: """ From 6e75ef6ba31a9f5cfe40ede2808a89bad5e0cd77 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 12:39:56 -0600 Subject: [PATCH 08/40] fix code duplicaiton --- specifyweb/backend/trees/extras.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 02c249f373c..cbfac9b9db6 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -467,23 +467,10 @@ def synonymize(node, into, agent, user=None, collection=None): "parentid": into.parent.id, "children": list(into.children.values('id', 'fullname')) }}) - node.accepted_id = target.id - node.isaccepted = False - node.save() - - # This check can be disabled by a remote pref - collection_prefs_dict = _get_collection_prefs_dict(collection, user) - - treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) - treeManagement_pref = treeManagement_pref if isinstance(treeManagement_pref, dict) else {} + tree_name = node.specify_model.name.lower() + add_synonym_enabled = _expand_synonymization_actions_enabled(collection, user, tree_name) - synonymized = treeManagement_pref.get('synonymized', {}) - synonymized = synonymized if isinstance(synonymized, dict) else {} - - pref_key = f"sp7.allow_adding_child_to_synonymized_parent.{node.specify_model.name}" - add_synonym_enabled = bool(synonymized.get(pref_key, False)) - - if (add_synonym_enabled is False) and node.children.exists(): + if not add_synonym_enabled and node.children.exists(): raise TreeBusinessRuleException( f'Synonymizing node "{node.fullname}" which has children', {"tree" : "Taxon", @@ -501,6 +488,10 @@ def synonymize(node, into, agent, user=None, collection=None): "parentid": into.parent.id, "children": list(into.children.values('id', 'fullname')) }}) + + node.accepted_id = target.id + node.isaccepted = False + node.save() node.acceptedchildren.update(**{node.accepted_id_attr().replace('_id', ''): target}) #assuming synonym can't be synonymized field_change_infos = [ From 32f2e2f2cdd11525a9b5952f7cc3c7c60719757e Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 12:48:06 -0600 Subject: [PATCH 09/40] revert update locality changes --- .gitignore | 1 + .../locality_update_tool/update_locality.py | 18 +++++------------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index c392ed07bab..961437150ca 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ seed-database /specifyweb/settings/debug.py /specifyweb/settings/secret_key.py /specifyweb/settings/ldap_settings.py +Dockerfile_Me /.mypy_cache /specifyweb/frontend/locale/**/*.mo /specifyweb/settings/build_version.py diff --git a/specifyweb/backend/locality_update_tool/update_locality.py b/specifyweb/backend/locality_update_tool/update_locality.py index 1fe2985daf8..8609d3248bc 100644 --- a/specifyweb/backend/locality_update_tool/update_locality.py +++ b/specifyweb/backend/locality_update_tool/update_locality.py @@ -379,19 +379,11 @@ def parse_locality_set(collection, raw_headers: list[str], data: list[list[str]] locality_id: int | None = None if len( locality_query) != 1 else locality_query[0].id - parsed_locality_fields = [ - parse_field( - collection, 'Locality', d['field'], d['value'], locality_id, row_number - ) - for d in locality_values - ] + parsed_locality_fields = [parse_field( + collection, 'Locality', dict['field'], dict['value'], locality_id, row_number) for dict in locality_values if dict['value'].strip() != ""] - parsed_geocoorddetail_fields = [ - parse_field( - collection, 'Geocoorddetail', d['field'], d['value'], locality_id, row_number - ) - for d in geocoorddetail_values - ] + parsed_geocoorddetail_fields = [parse_field( + collection, 'Geocoorddetail', dict["field"], dict['value'], locality_id, row_number) for dict in geocoorddetail_values if dict['value'].strip() != ""] parsed_row, parsed_errors = merge_parse_results( [*parsed_locality_fields, *parsed_geocoorddetail_fields], locality_id, row_number) @@ -522,4 +514,4 @@ def create_localityupdate_recordset(collection, specifyuser, locality_ids: list[ recordset=rs ) - return rs + return rs \ No newline at end of file From da5b90587eba4afd7c032e57f6f6efda567c0f49 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 12:56:54 -0600 Subject: [PATCH 10/40] remove old code segment --- specifyweb/backend/trees/extras.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index cbfac9b9db6..241f18568f8 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -277,14 +277,6 @@ def adding_node(node, collection=None, user=None): parent = model.objects.select_for_update().get(id=node.parent.id) if parent.accepted_id is not None: - collection_prefs_dict = _get_collection_prefs_dict(collection, user) - - treeManagement_pref = collection_prefs_dict.get('treeManagement', {}) - treeManagement_pref = treeManagement_pref if isinstance(treeManagement_pref, dict) else {} - - synonymized = treeManagement_pref.get('synonymized', {}) - synonymized = synonymized if isinstance(synonymized, dict) else {} - tree_name = node.specify_model.name add_synonym_enabled = _expand_synonymization_actions_enabled(collection, user, tree_name) From 0d769bc463b6f680a2b24194f209f8c991804036 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 15:32:34 -0600 Subject: [PATCH 11/40] change default behavior for _strict_synonymization_checks_enabled --- specifyweb/backend/trees/extras.py | 34 +++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 241f18568f8..32aeeac4366 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -242,32 +242,42 @@ def _get_collection_prefs_dict(collection, user, *, fail_silently: bool = False) return loaded if isinstance(loaded, dict) else {} -def _expand_synonymization_actions_enabled(collection, user, tree_name: str) -> bool: +def _strict_synonymization_checks_enabled(collection, user, tree_name: str) -> bool: """ New CollectionPreferences shape: treeManagement.expand_synonymization_actions. = true/false - + New CollectionPreferences shape (opt-in strict checking): + treeManagement.strict_synonymization_checks. = true/false + Backward compat with legacy shape: treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false + Backward compat with legacy shape (opt-in expanded behavior): + treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false """ prefs = _get_collection_prefs_dict(collection, user) - + tm = prefs.get("treeManagement") or {} if not isinstance(tm, dict): return False - + # New shape esa = tm.get("expand_synonymization_actions") if isinstance(esa, dict) and tree_name in esa: return bool(esa.get(tree_name)) - - # Legacy shape + strict = tm.get("strict_synonymization_checks") + if isinstance(strict, dict) and tree_name in strict: + return bool(strict.get(tree_name)) + + # Legacy shape syn = tm.get("synonymized") if isinstance(syn, dict): legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{tree_name}" if legacy_key in syn: return bool(syn.get(legacy_key)) - + # Legacy meaning: True means "allow expanded behavior" + legacy_allow_expand = bool(syn.get(legacy_key)) + return not legacy_allow_expand + # Default if nothing set return False @@ -277,10 +287,10 @@ def adding_node(node, collection=None, user=None): parent = model.objects.select_for_update().get(id=node.parent.id) if parent.accepted_id is not None: - tree_name = node.specify_model.name - add_synonym_enabled = _expand_synonymization_actions_enabled(collection, user, tree_name) + tree_name = node.specify_model.name.lower() + strict_checks = _strict_synonymization_checks_enabled(collection, user, tree_name) - if add_synonym_enabled is False: + if strict_checks: raise TreeBusinessRuleException( f'Adding node "{node.fullname}" to synonymized parent "{parent.fullname}"', {"tree" : "Taxon", @@ -460,9 +470,9 @@ def synonymize(node, into, agent, user=None, collection=None): "children": list(into.children.values('id', 'fullname')) }}) tree_name = node.specify_model.name.lower() - add_synonym_enabled = _expand_synonymization_actions_enabled(collection, user, tree_name) + strict_checks = _strict_synonymization_checks_enabled(collection, user, tree_name) - if not add_synonym_enabled and node.children.exists(): + if strict_checks and node.children.exists(): raise TreeBusinessRuleException( f'Synonymizing node "{node.fullname}" which has children', {"tree" : "Taxon", From 234c33f982987a12424350bc267e6b43a014c10b Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 15:53:24 -0600 Subject: [PATCH 12/40] _parse_locality_set path fix --- .../tests/test_parse_locality_set_foreground.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py b/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py index 0e0bdc92d2e..e5822e0c460 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py +++ b/specifyweb/backend/locality_update_tool/tests/test_parse_locality_set_foreground.py @@ -6,7 +6,7 @@ class TestParseLocalitySetForeground(ApiTests): - @patch("specifyweb.specify.views._parse_locality_set") + @patch("specifyweb.backend.locality_update_tool.views._parse_locality_set") def test_fail(self, parse_locality): errors = ParseError(message="guidHeaderNotProvided", field='guid', payload=None, row_number=0) @@ -17,7 +17,7 @@ def test_fail(self, parse_locality): self.assertEqual(status, 422) self.assertEqual(rows, [errors]) - @patch("specifyweb.specify.views._parse_locality_set") + @patch("specifyweb.backend.locality_update_tool.views._parse_locality_set") def test_success(self, parse_locality): parsed = [ParsedRow(row_number=0, locality=dict(localityname="Test"), geocoorddetail=None, locality_id=4)] From 46ddbd7a8c14695aa2c676914408cf9fa907f5e3 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 16:14:25 -0600 Subject: [PATCH 13/40] fix more patch imports --- .../tests/test_start_locality_set_background.py | 8 ++++---- .../tests/test_upload_locality_set_foreground.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py b/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py index bd19dde3106..104d972bc1a 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py +++ b/specifyweb/backend/locality_update_tool/tests/test_start_locality_set_background.py @@ -44,12 +44,12 @@ def _test_start_locality_set(self, parse_only: bool, task: Mock, uuid4: Mock): modifiedbyagent_id=self.agent.id )) - @patch('specifyweb.specify.views.uuid4') - @patch('specifyweb.specify.views.parse_locality_task') + @patch('specifyweb.backend.locality_update_tool.views.uuid4') + @patch('specifyweb.backend.locality_update_tool.views.parse_locality_task') def test_parse_only_task(self, parse_locality_task: Mock, uuid4: Mock): self._test_start_locality_set(True, parse_locality_task, uuid4) - @patch('specifyweb.specify.views.uuid4') - @patch('specifyweb.specify.views.update_locality_task') + @patch('specifyweb.backend.locality_update_tool.views.uuid4') + @patch('specifyweb.backend.locality_update_tool.views.update_locality_task') def test_upload_task(self, update_locality_task: Mock, uuid4: Mock): self._test_start_locality_set(False, update_locality_task, uuid4) \ No newline at end of file diff --git a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py index 9476b9c7f4a..3d619194bfb 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py +++ b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set_foreground.py @@ -16,7 +16,7 @@ def get_failure_result(*args, **kwargs): class TestUploadLocalitySetForeground(ApiTests): - @patch('specifyweb.specify.views._upload_locality_set', get_success_result) + @patch('specifyweb.backend.locality_update_tool.views._upload_locality_set', get_success_result) def test_success(self): result = upload_locality_set_foreground( @@ -31,8 +31,8 @@ def test_success(self): self.assertEqual(result, dict(type="Uploaded", results=[], recordsetid=None)) - @patch('specifyweb.specify.views.create_localityupdate_recordset') - @patch('specifyweb.specify.views._upload_locality_set', get_success_result) + @patch('specifyweb.backend.locality_update_tool.views.create_localityupdate_recordset') + @patch('specifyweb.backend.locality_update_tool.views._upload_locality_set', get_success_result) def test_success_record_set(self, create_recordset: Mock): recordset = Mock() recordset.pk = 9 @@ -49,7 +49,7 @@ def test_success_record_set(self, create_recordset: Mock): self.assertEqual(result, dict(type="Uploaded", results=[], recordsetid=9)) - @patch('specifyweb.specify.views._upload_locality_set', get_failure_result) + @patch('specifyweb.backend.locality_update_tool.views._upload_locality_set', get_failure_result) def test_failure(self): result = upload_locality_set_foreground( From 7610aae100654079c7021de240bbb588905b31fd Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 16:35:47 -0600 Subject: [PATCH 14/40] other patch import fixes --- specifyweb/backend/merge/tests/test_abort_merge_task.py | 6 +++--- specifyweb/backend/merge/tests/test_merging_status.py | 4 ++-- .../backend/merge/tests/test_record_merge_requests.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/specifyweb/backend/merge/tests/test_abort_merge_task.py b/specifyweb/backend/merge/tests/test_abort_merge_task.py index 6a67aa91425..9ce20eb93a7 100644 --- a/specifyweb/backend/merge/tests/test_abort_merge_task.py +++ b/specifyweb/backend/merge/tests/test_abort_merge_task.py @@ -61,18 +61,18 @@ def _revoke_test(self, record_merge_task: Mock, app_control: Mock, state): record_merge_task.AsyncResult.assert_called_once_with(test_id) app_control.revoke.assert_called_once_with(test_id, terminate=True) - @patch("specifyweb.specify.views.app.control") + @patch("specifyweb.backend.merge.views.app.control") @patch("specifyweb.backend.merge.record_merging.record_merge_task") def test_revoke_pending(self, record_merge_task: Mock, app_control: Mock): self._revoke_test(record_merge_task, app_control, "PENDING") - @patch("specifyweb.specify.views.app.control") + @patch("specifyweb.backend.merge.views.app.control") @patch("specifyweb.backend.merge.record_merging.record_merge_task") def test_revoke_merging(self, record_merge_task: Mock, app_control: Mock): self._revoke_test(record_merge_task, app_control, "MERGING") - @patch("specifyweb.specify.views.app.control") + @patch("specifyweb.backend.merge.views.app.control") @patch("specifyweb.backend.merge.record_merging.record_merge_task") def test_no_revoke(self, record_merge_task: Mock, app_control: Mock): test_id, merge_obj, response = self._revoke_setup(record_merge_task, app_control, "ABORTED") diff --git a/specifyweb/backend/merge/tests/test_merging_status.py b/specifyweb/backend/merge/tests/test_merging_status.py index 607c1ad328d..0e3b706ced8 100644 --- a/specifyweb/backend/merge/tests/test_merging_status.py +++ b/specifyweb/backend/merge/tests/test_merging_status.py @@ -46,12 +46,12 @@ def _existing_merge(self, info, record_merge_task): } ) - @patch("specifyweb.specify.views.record_merge_task") + @patch("specifyweb.backend.merge.views.record_merge_task") def test_existing_merge_dict_info(self, record_merge_task: Mock): self._existing_merge(dict(key="value"), record_merge_task) - @patch("specifyweb.specify.views.record_merge_task") + @patch("specifyweb.backend.merge.views.record_merge_task") def test_existing_merge_simple_info(self, record_merge_task: Mock): self._existing_merge("some simple str", record_merge_task) \ No newline at end of file diff --git a/specifyweb/backend/merge/tests/test_record_merge_requests.py b/specifyweb/backend/merge/tests/test_record_merge_requests.py index 2bb3eca5a82..10861dd37de 100644 --- a/specifyweb/backend/merge/tests/test_record_merge_requests.py +++ b/specifyweb/backend/merge/tests/test_record_merge_requests.py @@ -135,7 +135,7 @@ def _check_no_old_record_id(self, background: bool): ) self._assert_invalid_request(response) - @patch("specifyweb.specify.views.record_merge_task") + @patch("specifyweb.backend.merge.views.record_merge_task") def test_permissions_enforced_background(self, record_merge_task): test_id = "UUID_TEST_ID" _apply_async = Mock(return_value=MockResult(test_id)) @@ -172,8 +172,8 @@ def _validate_merge_setup( ) self.assertTrue(Spmerging.objects.filter(**filter_params).exists()) - @patch("specifyweb.specify.views.uuid4") - @patch("specifyweb.specify.views.record_merge_task") + @patch("specifyweb.backend.merge.views.uuid4") + @patch("specifyweb.backend.merge.views.record_merge_task") def test_other_merge_no_result(self, record_merge_task, uuid4): def __async_result(taskid): From 72a33714561226bd06f6dfd34ae5a46920b97d22 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 9 Feb 2026 17:03:16 -0600 Subject: [PATCH 15/40] init front-end data shape changes for tree management --- specifyweb/backend/trees/extras.py | 5 ++- .../components/DataModel/treeBusinessRules.ts | 32 ++++++++++++++++--- .../Preferences/CollectionDefinitions.tsx | 20 +++++++++++- .../lib/components/TreeView/Actions.tsx | 26 ++++++++++++--- 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 32aeeac4366..98f37762dd3 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -274,9 +274,8 @@ def _strict_synonymization_checks_enabled(collection, user, tree_name: str) -> b legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{tree_name}" if legacy_key in syn: return bool(syn.get(legacy_key)) - # Legacy meaning: True means "allow expanded behavior" - legacy_allow_expand = bool(syn.get(legacy_key)) - return not legacy_allow_expand + legacy_allow_expand = bool(syn.get(legacy_key)) + return not legacy_allow_expand # Default if nothing set return False diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 1e9da65a559..71fff915f7e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -1,6 +1,7 @@ import { treeText } from '../../localization/tree'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; +// import type { CollectionPreferences } from '../Preferences/collectionPreferences'; import { fetchPossibleRanks } from '../PickLists/TreeLevelPickList'; import { formatUrl } from '../Router/queryString'; import type { BusinessRuleResult } from './businessRules'; @@ -41,10 +42,9 @@ export const treeBusinessRules = async ( const prefModule = await import('../Preferences/collectionPreferences'); const collectionPreferences = prefModule.collectionPreferences; - const doExpandSynonymActionsPref = collectionPreferences.get( - 'treeManagement', - 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${resource.specifyTable.name}` + const strictChecksEnabled = getStrictSynonymizationChecksPref( + collectionPreferences, + resource.specifyTable.name ); const isParentSynonym = !parent.get('isAccepted'); @@ -52,7 +52,7 @@ export const treeBusinessRules = async ( const hasBadTreeStrcuture = parent.id === resource.id || definitionItem === undefined || - (isParentSynonym && !doExpandSynonymActionsPref) || + (isParentSynonym && strictChecksEnabled) || parent.get('rankId') >= definitionItem.get('rankId') || (possibleRanks !== undefined && !possibleRanks @@ -138,3 +138,25 @@ const predictFullName = async < headers: { Accept: 'text/plain' }, } ).then(({ data }) => data); + +function getStrictSynonymizationChecksPref( + collectionPreferences: CollectionPreferences, + tableName: string +): boolean { + // New preference (opt-in strict checking) + const strict = collectionPreferences.get( + 'treeManagement', + 'strict_synonymization_checks', + tableName + ); + if (typeof strict === 'boolean') return strict; + + // Legacy preference: true meant "allow expanded behavior" + // New meaning: strictChecksEnabled = !legacyAllowExpand + const legacyAllowExpand = collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${tableName}` + ); + return typeof legacyAllowExpand === 'boolean' ? !legacyAllowExpand : false; +} diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index 80093e9721c..08c17733090 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -61,6 +61,24 @@ export const collectionPreferenceDefinitions = { treeManagement: { title: treeText.treeManagement(), subCategories: { + strict_synonymization_checks: { + title: treeText.synonymizedNodes(), // or add a new string like “Strict synonym checks” + description: treeText.synonymizedNodesDescription(), // better: new description + items: { + Taxon: definePref({ + title: () => tableLabel('Taxon'), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), + Geography: definePref({ ... }), + Storage: definePref({ ... }), + GeologicTimePeriod: definePref({ ... }), + LithoStrat: definePref({ ... }), + TectonicUnit: definePref({ ... }), + }, + }, synonymized: { title: treeText.synonymizedNodes(), description: treeText.synonymizedNodesDescription(), @@ -69,7 +87,7 @@ export const collectionPreferenceDefinitions = { definePref({ title: () => tableLabel('Taxon'), requiresReload: false, - visible: true, + visible: false, defaultValue: false, type: 'java.lang.Boolean', }), diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index 28c3c8c7faf..68ed3607866 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -145,8 +145,7 @@ export function TreeViewActions({ addNew disabled={ focusedRow === undefined || - (doExpandSynonymActionsPref ? false : isSynonym) || - // Forbid adding children to the lowest rank + (isSynonym && strictChecksEnabled) || ranks.at(-1) === focusedRow.rankId } isRoot={false} @@ -198,9 +197,7 @@ export function TreeViewActions({ disableButtons || isReadOnly || isRoot || - (doExpandSynonymActionsPref - ? false - : !isSynonym && focusedRow.children > 0) + (strictChecksEnabled && !isSynonym && focusedRow.children > 0) } icon={isSynonym ? 'undoSynonym' : 'synonym'} title={isSynonym ? treeText.undoSynonymy() : treeText.synonymize()} @@ -516,3 +513,22 @@ function NodeDeleteButton({ /> ); } + +function getStrictSynonymizationChecksPref( + collectionPreferences: typeof import('../Preferences/collectionPreferences').collectionPreferences, + tableName: string +): boolean { + const strict = collectionPreferences.get( + 'treeManagement', + 'strict_synonymization_checks', + tableName + ); + if (typeof strict === 'boolean') return strict; + + const legacyAllowExpand = collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${tableName}` + ); + return typeof legacyAllowExpand === 'boolean' ? !legacyAllowExpand : false; +} From e481d387510cb2cb5379851abf938d6969419032 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 09:00:21 -0600 Subject: [PATCH 16/40] CollectionDefinition syntax fix --- .../Preferences/CollectionDefinitions.tsx | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index 08c17733090..91ab50c7c32 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -62,8 +62,8 @@ export const collectionPreferenceDefinitions = { title: treeText.treeManagement(), subCategories: { strict_synonymization_checks: { - title: treeText.synonymizedNodes(), // or add a new string like “Strict synonym checks” - description: treeText.synonymizedNodesDescription(), // better: new description + title: treeText.synonymizedNodes(), + description: treeText.synonymizedNodesDescription(), items: { Taxon: definePref({ title: () => tableLabel('Taxon'), @@ -72,11 +72,41 @@ export const collectionPreferenceDefinitions = { defaultValue: false, type: 'java.lang.Boolean', }), - Geography: definePref({ ... }), - Storage: definePref({ ... }), - GeologicTimePeriod: definePref({ ... }), - LithoStrat: definePref({ ... }), - TectonicUnit: definePref({ ... }), + Geography: definePref({ + title: () => tableLabel('Geography'), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), + Storage: definePref({ + title: () => tableLabel('Storage'), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), + GeologicTimePeriod: definePref({ + title: () => tableLabel('GeologicTimePeriod'), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), + LithoStrat: definePref({ + title: () => tableLabel('LithoStrat'), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), + TectonicUnit: definePref({ + title: () => tableLabel('TectonicUnit'), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), }, }, synonymized: { From 98b211525a8af2fa1b406a9a1925f1bb76ad1074 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 10:16:32 -0600 Subject: [PATCH 17/40] fix front-end typeschecks --- .../components/DataModel/treeBusinessRules.ts | 42 ++++++++++++++-- .../lib/components/TreeView/Actions.tsx | 48 ++++++++++++++++--- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 71fff915f7e..98c86586a15 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -1,7 +1,6 @@ import { treeText } from '../../localization/tree'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; -// import type { CollectionPreferences } from '../Preferences/collectionPreferences'; import { fetchPossibleRanks } from '../PickLists/TreeLevelPickList'; import { formatUrl } from '../Router/queryString'; import type { BusinessRuleResult } from './businessRules'; @@ -40,8 +39,7 @@ export const treeBusinessRules = async ( idFromUrl(parentDefItem.get('treeDef'))! ); - const prefModule = await import('../Preferences/collectionPreferences'); - const collectionPreferences = prefModule.collectionPreferences; + const { collectionPreferences } = await import('../Preferences/collectionPreferences'); const strictChecksEnabled = getStrictSynonymizationChecksPref( collectionPreferences, resource.specifyTable.name @@ -139,10 +137,44 @@ const predictFullName = async < } ).then(({ data }) => data); +const strictTrees = [ + 'Taxon', + 'Geography', + 'Storage', + 'GeologicTimePeriod', + 'LithoStrat', + 'TectonicUnit', +] as const; + +type StrictTreeName = (typeof strictTrees)[number]; + +const isStrictTreeName = (value: string): value is StrictTreeName => + (strictTrees as readonly string[]).includes(value); + +const legacyExpandKeyByTree: Record< + StrictTreeName, + | 'sp7.allow_adding_child_to_synonymized_parent.Taxon' + | 'sp7.allow_adding_child_to_synonymized_parent.Geography' + | 'sp7.allow_adding_child_to_synonymized_parent.Storage' + | 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod' + | 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat' + | 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit' +> = { + Taxon: 'sp7.allow_adding_child_to_synonymized_parent.Taxon', + Geography: 'sp7.allow_adding_child_to_synonymized_parent.Geography', + Storage: 'sp7.allow_adding_child_to_synonymized_parent.Storage', + GeologicTimePeriod: + 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod', + LithoStrat: 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat', + TectonicUnit: 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit', +} as const; + function getStrictSynonymizationChecksPref( - collectionPreferences: CollectionPreferences, + collectionPreferences: { get: (...args: any[]) => unknown }, tableName: string ): boolean { + if (!isStrictTreeName(tableName)) return false; + // New preference (opt-in strict checking) const strict = collectionPreferences.get( 'treeManagement', @@ -156,7 +188,7 @@ function getStrictSynonymizationChecksPref( const legacyAllowExpand = collectionPreferences.get( 'treeManagement', 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${tableName}` + legacyExpandKeyByTree[tableName] ); return typeof legacyAllowExpand === 'boolean' ? !legacyAllowExpand : false; } diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index 68ed3607866..661f361fefe 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -69,10 +69,9 @@ export function TreeViewActions({ const resourceName = `/tree/edit/${toLowerCase(tableName)}` as const; const isSynonym = typeof focusedRow?.acceptedId === 'number'; - const doExpandSynonymActionsPref = collectionPreferences.get( - 'treeManagement', - 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${tableName}` + const strictChecksEnabled = getStrictSynonymizationChecksPref( + collectionPreferences, + tableName ); const disableButtons = @@ -514,10 +513,45 @@ function NodeDeleteButton({ ); } +const strictTrees = [ + 'Taxon', + 'Geography', + 'Storage', + 'GeologicTimePeriod', + 'LithoStrat', + 'TectonicUnit', +] as const; + +type StrictTreeName = (typeof strictTrees)[number]; + +const isStrictTreeName = (value: string): value is StrictTreeName => + (strictTrees as readonly string[]).includes(value); + +const legacyExpandKeyByTree: Record< + StrictTreeName, + | 'sp7.allow_adding_child_to_synonymized_parent.Taxon' + | 'sp7.allow_adding_child_to_synonymized_parent.Geography' + | 'sp7.allow_adding_child_to_synonymized_parent.Storage' + | 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod' + | 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat' + | 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit' +> = { + Taxon: 'sp7.allow_adding_child_to_synonymized_parent.Taxon', + Geography: 'sp7.allow_adding_child_to_synonymized_parent.Geography', + Storage: 'sp7.allow_adding_child_to_synonymized_parent.Storage', + GeologicTimePeriod: + 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod', + LithoStrat: 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat', + TectonicUnit: 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit', +} as const; + function getStrictSynonymizationChecksPref( - collectionPreferences: typeof import('../Preferences/collectionPreferences').collectionPreferences, + collectionPreferences: { get: (...args: any[]) => unknown }, tableName: string ): boolean { + if (!isStrictTreeName(tableName)) return false; + + // New preference (opt-in strict checking) const strict = collectionPreferences.get( 'treeManagement', 'strict_synonymization_checks', @@ -525,10 +559,12 @@ function getStrictSynonymizationChecksPref( ); if (typeof strict === 'boolean') return strict; + // Legacy preference: true meant "allow expanded behavior" + // New meaning: strictChecksEnabled = !legacyAllowExpand const legacyAllowExpand = collectionPreferences.get( 'treeManagement', 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${tableName}` + legacyExpandKeyByTree[tableName] ); return typeof legacyAllowExpand === 'boolean' ? !legacyAllowExpand : false; } From 2bdd3c0810b013f1a9f4a478a89d377d95b6036d Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 10:25:11 -0600 Subject: [PATCH 18/40] fix getStrictSynonymizationChecksPref --- .../frontend/js_src/lib/components/TreeView/Actions.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index 661f361fefe..82aec1bfe5c 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -551,7 +551,6 @@ function getStrictSynonymizationChecksPref( ): boolean { if (!isStrictTreeName(tableName)) return false; - // New preference (opt-in strict checking) const strict = collectionPreferences.get( 'treeManagement', 'strict_synonymization_checks', @@ -559,12 +558,12 @@ function getStrictSynonymizationChecksPref( ); if (typeof strict === 'boolean') return strict; - // Legacy preference: true meant "allow expanded behavior" - // New meaning: strictChecksEnabled = !legacyAllowExpand const legacyAllowExpand = collectionPreferences.get( 'treeManagement', 'synonymized', legacyExpandKeyByTree[tableName] ); - return typeof legacyAllowExpand === 'boolean' ? !legacyAllowExpand : false; + if (typeof legacyAllowExpand === 'boolean') return !legacyAllowExpand; + + return true; } From deed0eea3af7682d0763b00442e9a69a1d0a2ed3 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 10:46:38 -0600 Subject: [PATCH 19/40] export function getStrictSynonymizationChecksPref --- specifyweb/backend/trees/extras.py | 4 +- .../components/DataModel/treeBusinessRules.ts | 16 ++++-- .../lib/components/TreeView/Actions.tsx | 56 +------------------ 3 files changed, 14 insertions(+), 62 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 98f37762dd3..4b217cdc84c 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -286,7 +286,7 @@ def adding_node(node, collection=None, user=None): parent = model.objects.select_for_update().get(id=node.parent.id) if parent.accepted_id is not None: - tree_name = node.specify_model.name.lower() + tree_name = node.specify_model.name strict_checks = _strict_synonymization_checks_enabled(collection, user, tree_name) if strict_checks: @@ -468,7 +468,7 @@ def synonymize(node, into, agent, user=None, collection=None): "parentid": into.parent.id, "children": list(into.children.values('id', 'fullname')) }}) - tree_name = node.specify_model.name.lower() + tree_name = node.specify_model.name strict_checks = _strict_synonymization_checks_enabled(collection, user, tree_name) if strict_checks and node.children.exists(): diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 98c86586a15..fe808f85add 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -169,13 +169,12 @@ const legacyExpandKeyByTree: Record< TectonicUnit: 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit', } as const; -function getStrictSynonymizationChecksPref( +export function getStrictSynonymizationChecksPref( collectionPreferences: { get: (...args: any[]) => unknown }, tableName: string ): boolean { if (!isStrictTreeName(tableName)) return false; - // New preference (opt-in strict checking) const strict = collectionPreferences.get( 'treeManagement', 'strict_synonymization_checks', @@ -183,12 +182,19 @@ function getStrictSynonymizationChecksPref( ); if (typeof strict === 'boolean') return strict; - // Legacy preference: true meant "allow expanded behavior" - // New meaning: strictChecksEnabled = !legacyAllowExpand + const expand = collectionPreferences.get( + 'treeManagement', + 'expand_synonymization_actions', + tableName + ); + if (typeof expand === 'boolean') return !expand; + const legacyAllowExpand = collectionPreferences.get( 'treeManagement', 'synonymized', legacyExpandKeyByTree[tableName] ); - return typeof legacyAllowExpand === 'boolean' ? !legacyAllowExpand : false; + if (typeof legacyAllowExpand === 'boolean') return !legacyAllowExpand; + + return true; } diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index 82aec1bfe5c..23dc8a2178d 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -23,6 +23,7 @@ import { hasPermission, hasTablePermission } from '../Permissions/helpers'; import { collectionPreferences } from '../Preferences/collectionPreferences'; import type { Row } from './helpers'; import { checkMoveViolatesEnforced } from './helpers'; +import { getStrictSynonymizationChecksPref } from '../DataModel/treeBusinessRules'; const treeActions = [ 'add', @@ -512,58 +513,3 @@ function NodeDeleteButton({ /> ); } - -const strictTrees = [ - 'Taxon', - 'Geography', - 'Storage', - 'GeologicTimePeriod', - 'LithoStrat', - 'TectonicUnit', -] as const; - -type StrictTreeName = (typeof strictTrees)[number]; - -const isStrictTreeName = (value: string): value is StrictTreeName => - (strictTrees as readonly string[]).includes(value); - -const legacyExpandKeyByTree: Record< - StrictTreeName, - | 'sp7.allow_adding_child_to_synonymized_parent.Taxon' - | 'sp7.allow_adding_child_to_synonymized_parent.Geography' - | 'sp7.allow_adding_child_to_synonymized_parent.Storage' - | 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod' - | 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat' - | 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit' -> = { - Taxon: 'sp7.allow_adding_child_to_synonymized_parent.Taxon', - Geography: 'sp7.allow_adding_child_to_synonymized_parent.Geography', - Storage: 'sp7.allow_adding_child_to_synonymized_parent.Storage', - GeologicTimePeriod: - 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod', - LithoStrat: 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat', - TectonicUnit: 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit', -} as const; - -function getStrictSynonymizationChecksPref( - collectionPreferences: { get: (...args: any[]) => unknown }, - tableName: string -): boolean { - if (!isStrictTreeName(tableName)) return false; - - const strict = collectionPreferences.get( - 'treeManagement', - 'strict_synonymization_checks', - tableName - ); - if (typeof strict === 'boolean') return strict; - - const legacyAllowExpand = collectionPreferences.get( - 'treeManagement', - 'synonymized', - legacyExpandKeyByTree[tableName] - ); - if (typeof legacyAllowExpand === 'boolean') return !legacyAllowExpand; - - return true; -} From c4cd087cddfa087fbe078f6cf0bc5c2d7e1c7050 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 10:52:52 -0600 Subject: [PATCH 20/40] fix businessRules.test.ts --- .../components/DataModel/__tests__/businessRules.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..216adb26bd7 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 @@ -901,9 +901,9 @@ describe('treeBusinessRules', () => { ...originalRaw, treeManagement: { ...originalRaw.treeManagement, - synonymized: { - ...originalRaw.treeManagement?.synonymized, - 'sp7.allow_adding_child_to_synonymized_parent.Taxon': true, + strict_synonymization_checks: { + ...(originalRaw.treeManagement as any)?.strict_synonymization_checks, + Taxon: false, }, }, } as typeof originalRaw); From 8ae1f7497d4553c9afdbe9503269479a9e4e09ac Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 11:07:20 -0600 Subject: [PATCH 21/40] jest ajaxMock error fixes --- .../components/DataModel/__tests__/businessRules.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 216adb26bd7..33c98bbf636 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 @@ -21,6 +21,10 @@ import type { TaxonTreeDefItem, } from '../types'; +overrideAjax('/context/collection_resource/', { + collectionPreferences: {}, +}); + mockTime(); requireContext(); @@ -846,6 +850,7 @@ describe('treeBusinessRules', () => { }); await taxon.businessRuleManager?.checkField('parent'); + await taxon.businessRuleManager?.pendingPromise; const { result } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -863,6 +868,7 @@ describe('treeBusinessRules', () => { }); await taxon.businessRuleManager?.checkField('parent'); + await taxon.businessRuleManager?.pendingPromise; const { result } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -879,6 +885,7 @@ describe('treeBusinessRules', () => { }); await taxon.businessRuleManager?.checkField('parent'); + await taxon.businessRuleManager?.pendingPromise; const { result } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -917,6 +924,7 @@ describe('treeBusinessRules', () => { }); await taxon.businessRuleManager?.checkField('parent'); + await taxon.businessRuleManager?.pendingPromise; const { result } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) From 2b0f15dee1dab9b68789a377611fc19c316f6590 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 11:41:00 -0600 Subject: [PATCH 22/40] redo getStrictSynonymizationChecksPref implementation --- .../DataModel/__tests__/businessRules.test.ts | 61 ++++++----- .../components/DataModel/treeBusinessRules.ts | 101 ++++++++---------- 2 files changed, 81 insertions(+), 81 deletions(-) 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 33c98bbf636..576dc693098 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 @@ -12,6 +12,7 @@ import { useSaveBlockers } from '../saveBlockers'; import { schema } from '../schema'; import type { SpecifyTable } from '../specifyTable'; import { tables } from '../tables'; +import { collectionPreferences } from '../../Preferences/collectionPreferences'; import type { CollectingEvent, CollectionObjectType, @@ -21,8 +22,11 @@ import type { TaxonTreeDefItem, } from '../types'; +// overrideAjax('/context/collection_resource/', []); overrideAjax('/context/collection_resource/', { - collectionPreferences: {}, + collectionPreferences: [], + userPreferences: [], + disciplinePreferences: [], }); mockTime(); @@ -893,6 +897,7 @@ describe('treeBusinessRules', () => { expect(result.current[0]).toStrictEqual(['Bad tree structure.']); await taxon.businessRuleManager?.checkField('integer1'); + await taxon.businessRuleManager?.pendingPromise; const { result: fieldChangeResult } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -900,35 +905,37 @@ describe('treeBusinessRules', () => { expect(fieldChangeResult.current[0]).toStrictEqual(['Bad tree structure.']); }); test('saveBlocker not on synonymized parent w/preference', async () => { - const { collectionPreferences } = await import( - '../../Preferences/collectionPreferences' - ); const originalRaw = collectionPreferences.getRaw(); - collectionPreferences.setRaw({ - ...originalRaw, - treeManagement: { - ...originalRaw.treeManagement, - strict_synonymization_checks: { - ...(originalRaw.treeManagement as any)?.strict_synonymization_checks, - Taxon: false, - }, - }, - } as typeof originalRaw); - const taxon = new tables.Taxon.Resource({ - name: 'dauricus', - parent: '/api/specify/taxon/6/', - rankId: 220, - definition: '/api/specify/taxontreedef/1/', - definitionItem: '/api/specify/taxontreedefitem/2/', - }); + try { + collectionPreferences.setRaw({ + ...originalRaw, + treeManagement: { + ...originalRaw.treeManagement, + strict_synonymization_checks: { + ...(originalRaw.treeManagement as any)?.strict_synonymization_checks, + Taxon: false, + }, + }, + } as typeof originalRaw); + + const taxon = new tables.Taxon.Resource({ + name: 'dauricus', + parent: '/api/specify/taxon/6/', + rankId: 220, + definition: '/api/specify/taxontreedef/1/', + definitionItem: '/api/specify/taxontreedefitem/2/', + }); - await taxon.businessRuleManager?.checkField('parent'); - await taxon.businessRuleManager?.pendingPromise; + await taxon.businessRuleManager?.checkField('parent'); + await taxon.businessRuleManager?.pendingPromise; - const { result } = renderHook(() => - useSaveBlockers(taxon, tables.Taxon.getField('parent')) - ); - expect(result.current[0]).toStrictEqual([]); + const { result } = renderHook(() => + useSaveBlockers(taxon, tables.Taxon.getField('parent')) + ); + expect(result.current[0]).toStrictEqual([]); + } finally { + collectionPreferences.setRaw(originalRaw); + } }); }); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index fe808f85add..d73d81ba00b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -8,6 +8,7 @@ import type { AnyTree, TableFields } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; import { idFromUrl } from './resource'; import type { Tables } from './types'; +import { collectionPreferences } from '../Preferences/collectionPreferences'; // eslint-disable-next-line unicorn/prevent-abbreviations export type TreeDefItem = @@ -39,7 +40,6 @@ export const treeBusinessRules = async ( idFromUrl(parentDefItem.get('treeDef'))! ); - const { collectionPreferences } = await import('../Preferences/collectionPreferences'); const strictChecksEnabled = getStrictSynonymizationChecksPref( collectionPreferences, resource.specifyTable.name @@ -137,64 +137,57 @@ const predictFullName = async < } ).then(({ data }) => data); -const strictTrees = [ - 'Taxon', - 'Geography', - 'Storage', - 'GeologicTimePeriod', - 'LithoStrat', - 'TectonicUnit', -] as const; - -type StrictTreeName = (typeof strictTrees)[number]; - -const isStrictTreeName = (value: string): value is StrictTreeName => - (strictTrees as readonly string[]).includes(value); - -const legacyExpandKeyByTree: Record< - StrictTreeName, - | 'sp7.allow_adding_child_to_synonymized_parent.Taxon' - | 'sp7.allow_adding_child_to_synonymized_parent.Geography' - | 'sp7.allow_adding_child_to_synonymized_parent.Storage' - | 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod' - | 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat' - | 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit' -> = { - Taxon: 'sp7.allow_adding_child_to_synonymized_parent.Taxon', - Geography: 'sp7.allow_adding_child_to_synonymized_parent.Geography', - Storage: 'sp7.allow_adding_child_to_synonymized_parent.Storage', - GeologicTimePeriod: - 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod', - LithoStrat: 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat', - TectonicUnit: 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit', -} as const; +const STRICT_TREES = new Set([ + 'taxon', + 'geography', + 'storage', + 'geologictimeperiod', + 'lithostrat', + 'tectonicunit', +]); + +const canonicalPrefKey = (tableName: string): string => + tableName.length === 0 + ? tableName + : tableName[0].toUpperCase() + tableName.slice(1).toLowerCase(); export function getStrictSynonymizationChecksPref( collectionPreferences: { get: (...args: any[]) => unknown }, tableName: string ): boolean { - if (!isStrictTreeName(tableName)) return false; - - const strict = collectionPreferences.get( - 'treeManagement', - 'strict_synonymization_checks', - tableName - ); - if (typeof strict === 'boolean') return strict; - - const expand = collectionPreferences.get( - 'treeManagement', - 'expand_synonymization_actions', - tableName - ); - if (typeof expand === 'boolean') return !expand; - - const legacyAllowExpand = collectionPreferences.get( - 'treeManagement', - 'synonymized', - legacyExpandKeyByTree[tableName] - ); - if (typeof legacyAllowExpand === 'boolean') return !legacyAllowExpand; + const normalized = tableName.toLowerCase(); + if (!STRICT_TREES.has(normalized)) return false; + + const keyTitle = canonicalPrefKey(normalized); + const keyRaw = tableName; + + const strictValue = + (collectionPreferences.get( + 'treeManagement', + 'strict_synonymization_checks', + keyRaw + ) as unknown) ?? + (collectionPreferences.get( + 'treeManagement', + 'strict_synonymization_checks', + keyTitle + ) as unknown); + + if (typeof strictValue === 'boolean') return strictValue; + + const legacyAllow = + (collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${keyRaw}` + ) as unknown) ?? + (collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${keyTitle}` + ) as unknown); + + if (typeof legacyAllow === 'boolean') return !legacyAllow; return true; } From d3ac97e6ad9d27a5077bd9fa250c8d677f9e116c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 12:10:42 -0600 Subject: [PATCH 23/40] circular import fixes in treeBusinessRules --- .../components/DataModel/__tests__/businessRules.test.ts | 7 +++---- .../js_src/lib/components/DataModel/treeBusinessRules.ts | 7 ++++++- 2 files changed, 9 insertions(+), 5 deletions(-) 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 576dc693098..fbaf2d5aa5f 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 @@ -22,11 +22,10 @@ import type { TaxonTreeDefItem, } from '../types'; -// overrideAjax('/context/collection_resource/', []); overrideAjax('/context/collection_resource/', { - collectionPreferences: [], - userPreferences: [], - disciplinePreferences: [], + collectionPreferences: {}, + userPreferences: {}, + disciplinePreferences: {}, }); mockTime(); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index d73d81ba00b..32ce90e55e2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -8,7 +8,11 @@ import type { AnyTree, TableFields } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; import { idFromUrl } from './resource'; import type { Tables } from './types'; -import { collectionPreferences } from '../Preferences/collectionPreferences'; + +const getCollectionPreferences = async () => { + const mod = await import('../Preferences/collectionPreferences'); + return mod.collectionPreferences; +}; // eslint-disable-next-line unicorn/prevent-abbreviations export type TreeDefItem = @@ -40,6 +44,7 @@ export const treeBusinessRules = async ( idFromUrl(parentDefItem.get('treeDef'))! ); + const collectionPreferences = await getCollectionPreferences(); const strictChecksEnabled = getStrictSynonymizationChecksPref( collectionPreferences, resource.specifyTable.name From a1b9d72dc08c0a8ae24673792e9d33c40e6f9c64 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 12:30:49 -0600 Subject: [PATCH 24/40] try jest.mock --- .../DataModel/__tests__/businessRules.test.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) 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 fbaf2d5aa5f..b17aa3e80ea 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 @@ -22,6 +22,33 @@ import type { TaxonTreeDefItem, } from '../types'; +jest.mock('../../Preferences/collectionPreferences', () => { + let raw: any = { + treeManagement: { + strict_synonymization_checks: {}, + synonymized: {}, + }, + }; + + const get = (...path: any[]): unknown => { + return path.reduce((acc, key) => (acc == null ? acc : acc[key]), raw); + }; + + const getRaw = (): any => raw; + + const setRaw = (next: any): void => { + raw = next; + }; + + return { + collectionPreferences: { + get, + getRaw, + setRaw, + }, + }; +}); + overrideAjax('/context/collection_resource/', { collectionPreferences: {}, userPreferences: {}, @@ -904,6 +931,10 @@ describe('treeBusinessRules', () => { expect(fieldChangeResult.current[0]).toStrictEqual(['Bad tree structure.']); }); test('saveBlocker not on synonymized parent w/preference', async () => { + const { collectionPreferences } = await import( + '../../Preferences/collectionPreferences' + ); + const originalRaw = collectionPreferences.getRaw(); try { @@ -912,11 +943,11 @@ describe('treeBusinessRules', () => { treeManagement: { ...originalRaw.treeManagement, strict_synonymization_checks: { - ...(originalRaw.treeManagement as any)?.strict_synonymization_checks, + ...(originalRaw.treeManagement?.strict_synonymization_checks ?? {}), Taxon: false, }, }, - } as typeof originalRaw); + }); const taxon = new tables.Taxon.Resource({ name: 'dauricus', From d9c793e64f01d54ae28abd3747dea45a067d75a1 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 12:34:22 -0600 Subject: [PATCH 25/40] collectionPreferences import change --- .../lib/components/DataModel/__tests__/businessRules.test.ts | 1 - 1 file changed, 1 deletion(-) 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 b17aa3e80ea..d504dfaa8b7 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 @@ -12,7 +12,6 @@ import { useSaveBlockers } from '../saveBlockers'; import { schema } from '../schema'; import type { SpecifyTable } from '../specifyTable'; import { tables } from '../tables'; -import { collectionPreferences } from '../../Preferences/collectionPreferences'; import type { CollectingEvent, CollectionObjectType, From fa3ac7b467e649eeb1e4701262a7871af104e6a4 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 18:38:30 +0000 Subject: [PATCH 26/40] Lint code with ESLint and Prettier Triggered by d9c793e64f01d54ae28abd3747dea45a067d75a1 on branch refs/heads/issue-7662 --- .../components/DataModel/__tests__/businessRules.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 d504dfaa8b7..193bde74812 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 @@ -29,9 +29,7 @@ jest.mock('../../Preferences/collectionPreferences', () => { }, }; - const get = (...path: any[]): unknown => { - return path.reduce((acc, key) => (acc == null ? acc : acc[key]), raw); - }; + const get = (...path: readonly any[]): unknown => path.reduce((accumulator, key) => (accumulator == null ? accumulator : accumulator[key]), raw); const getRaw = (): any => raw; @@ -942,7 +940,7 @@ describe('treeBusinessRules', () => { treeManagement: { ...originalRaw.treeManagement, strict_synonymization_checks: { - ...(originalRaw.treeManagement?.strict_synonymization_checks ?? {}), + ...(originalRaw.treeManagement?.strict_synonymization_checks), Taxon: false, }, }, From b05a03b3072a0509a52a6053175f0a5ce5266b95 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 13:10:15 -0600 Subject: [PATCH 27/40] fix ajax mock during jest teardown --- .../frontend/js_src/lib/tests/ajax/index.ts | 30 +++++++++++++++---- .../frontend/js_src/lib/utils/ajax/index.ts | 13 ++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts index 286814f5e8b..6a9777ba03a 100644 --- a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts @@ -71,9 +71,11 @@ export function overrideAjax( } const basePathParts = process.cwd().split('/'); -const basePath = basePathParts - .slice(0, basePathParts.indexOf('js_src') + 1) - .join('/'); +const jsSrcPathIndex = basePathParts.indexOf('js_src'); +const basePath = + jsSrcPathIndex === -1 + ? process.cwd() + : basePathParts.slice(0, jsSrcPathIndex + 1).join('/'); /** * When process.env.NODE_ENV === 'test', this intercepts the AJAX requests @@ -98,7 +100,19 @@ export async function ajaxMock( if (url.startsWith('https://stats.specifycloud.org/capture')) return formatResponse('', accept, expectedErrors, undefined); - const parsedUrl = new URL(url, globalThis?.location.origin); + const safeOrigin = (() => { + try { + const origin = globalThis.location?.origin; + return typeof origin === 'string' ? origin : 'http://localhost'; + } catch { + return 'http://localhost'; + } + })(); + + const parsedUrl = new URL( + url, + safeOrigin + ); const urlWithoutQuery = `${parsedUrl.origin}${parsedUrl.pathname}`; const overwrittenData = overrides[url]?.[requestMethod] ?? @@ -117,7 +131,13 @@ export async function ajaxMock( * Windows. */ const [splitUrl, queryString = ''] = url.split('?'); - const parsedPath = path.parse(`${basePath}/lib/tests/ajax/static${splitUrl}`); + const normalizedUrl = + splitUrl !== '/' && splitUrl.endsWith('/') + ? splitUrl.slice(0, -1) + : splitUrl; + const parsedPath = path.parse( + `${basePath}/lib/tests/ajax/static${normalizedUrl}` + ); const directoryName = queryString === '' ? parsedPath.dir diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts index eb8b2299579..d8a16fd4674 100644 --- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts @@ -56,6 +56,13 @@ const safeMethods: ReadonlySet = new Set([ 'HEAD', ]); +const testAjaxMockPromise: Promise< + (typeof import('../../tests/ajax'))['ajaxMock'] | undefined +> = + process.env.NODE_ENV === 'test' + ? import('../../tests/ajax').then(({ ajaxMock }) => ajaxMock) + : Promise.resolve(undefined); + export type AjaxProps = Omit & { readonly method?: AjaxMethod; /** @@ -110,8 +117,10 @@ export async function ajax( */ // REFACTOR: replace this with a mock if (process.env.NODE_ENV === 'test') { - const { ajaxMock } = await import('../../tests/ajax'); - return ajaxMock(url, { + const ajaxMock = await testAjaxMockPromise; + if (typeof ajaxMock !== 'function') + throw new Error('Failed to initialize ajax test mock'); + return ajaxMock(url, { headers: { Accept: accept, ...headers }, method, expectedErrors, From 1fb27e7dd598ff4c1534721b813dc3020d7a909c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 19:14:13 +0000 Subject: [PATCH 28/40] Lint code with ESLint and Prettier Triggered by b05a03b3072a0509a52a6053175f0a5ce5266b95 on branch refs/heads/issue-7662 --- specifyweb/frontend/js_src/lib/tests/ajax/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts index 6a9777ba03a..550625fb5fb 100644 --- a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts @@ -71,11 +71,11 @@ export function overrideAjax( } const basePathParts = process.cwd().split('/'); -const jsSrcPathIndex = basePathParts.indexOf('js_src'); +const jsSourcePathIndex = basePathParts.indexOf('js_src'); const basePath = - jsSrcPathIndex === -1 + jsSourcePathIndex === -1 ? process.cwd() - : basePathParts.slice(0, jsSrcPathIndex + 1).join('/'); + : basePathParts.slice(0, jsSourcePathIndex + 1).join('/'); /** * When process.env.NODE_ENV === 'test', this intercepts the AJAX requests From 385574b4373835b3a3817a51ebed6563f36b6e4f Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 13:19:26 -0600 Subject: [PATCH 29/40] avoid collectionPreferences fetch mock error --- .../js_src/lib/components/Errors/stackTrace.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts b/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts index bcd2c11d874..84fcc4d011b 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts +++ b/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts @@ -3,6 +3,16 @@ import type { R } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { consoleLog } from './interceptLogs'; +const resolvePreferences = async (preferences: { + readonly fetch?: () => Promise; + readonly getRaw?: () => unknown; +}): Promise => { + if (typeof preferences.fetch === 'function') await preferences.fetch(); + return typeof preferences.getRaw === 'function' + ? preferences.getRaw() + : undefined; +}; + const resolvedStackTrace: R = {}; Promise.all( Object.entries({ @@ -25,12 +35,11 @@ Promise.all( async ({ fetchContext }) => fetchContext ), userPreferences: import('../Preferences/userPreferences').then( - async ({ userPreferences }) => - userPreferences.fetch().then(() => userPreferences.getRaw()) + async ({ userPreferences }) => resolvePreferences(userPreferences) ), collectionPreferences: import('../Preferences/collectionPreferences').then( async ({ collectionPreferences }) => - collectionPreferences.fetch().then(() => collectionPreferences.getRaw()) + resolvePreferences(collectionPreferences) ), userInformation: import('../InitialContext/userInformation').then( async ({ fetchContext }) => fetchContext From 90457e7d8d9a10466abfd53b36ed989db293573e Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 14:32:43 -0600 Subject: [PATCH 30/40] add new synonymize tests --- specifyweb/backend/trees/extras.py | 49 ++++---- .../tests/test_tree_extras/test_synonymize.py | 29 ++++- .../DataModel/__tests__/businessRules.test.ts | 96 ++++++++++----- .../components/DataModel/treeBusinessRules.ts | 115 +++++++++++------- .../Preferences/CollectionDefinitions.tsx | 12 +- 5 files changed, 201 insertions(+), 100 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 4b217cdc84c..2cf56ed2099 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -245,40 +245,47 @@ def _get_collection_prefs_dict(collection, user, *, fail_silently: bool = False) def _strict_synonymization_checks_enabled(collection, user, tree_name: str) -> bool: """ New CollectionPreferences shape: - treeManagement.expand_synonymization_actions. = true/false - New CollectionPreferences shape (opt-in strict checking): treeManagement.strict_synonymization_checks. = true/false Backward compat with legacy shape: treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false - Backward compat with legacy shape (opt-in expanded behavior): - treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false """ - prefs = _get_collection_prefs_dict(collection, user) + prefs = _get_collection_prefs_dict(collection, user, fail_silently=True) tm = prefs.get("treeManagement") or {} if not isinstance(tm, dict): + tm = {} + + strict_tree_key_map = { + "taxon": "Taxon", + "geography": "Geography", + "storage": "Storage", + "geologictimeperiod": "GeologicTimePeriod", + "lithostrat": "LithoStrat", + "tectonicunit": "TectonicUnit", + } + normalized_name = tree_name.lower() + title_name = strict_tree_key_map.get(normalized_name) + if title_name is None: return False - - # New shape - esa = tm.get("expand_synonymization_actions") - if isinstance(esa, dict) and tree_name in esa: - return bool(esa.get(tree_name)) + + key_candidates = [tree_name, title_name, normalized_name] + strict = tm.get("strict_synonymization_checks") - if isinstance(strict, dict) and tree_name in strict: - return bool(strict.get(tree_name)) + if isinstance(strict, dict): + for key in key_candidates: + if key in strict and isinstance(strict.get(key), bool): + return strict[key] - # Legacy shape + # Legacy shape "allow expanded behavior" syn = tm.get("synonymized") if isinstance(syn, dict): - legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{tree_name}" - if legacy_key in syn: - return bool(syn.get(legacy_key)) - legacy_allow_expand = bool(syn.get(legacy_key)) - return not legacy_allow_expand + for key in key_candidates: + legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{key}" + if legacy_key in syn and isinstance(syn.get(legacy_key), bool): + return not syn[legacy_key] - # Default if nothing set - return False + return True def adding_node(node, collection=None, user=None): logger.info('adding node %s', node) @@ -893,4 +900,4 @@ def tree_path_expr(tbl: str, d: int) -> str: # replace path_expr if ordering iss from specifyweb.specify.models import datamodel, Sptasksemaphore tree_model = datamodel.get_table(table) tasknames = [name.format(tree_model.name) for name in ("UpdateNodes{}", "BadNodes{}")] - Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) \ No newline at end of file + Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) diff --git a/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py b/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py index ef724e0c7fe..10fca108130 100644 --- a/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py +++ b/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException from specifyweb.specify.models import Determination, Taxon, Taxontreedef from specifyweb.backend.trees.tests.test_trees import GeographyTree @@ -141,4 +143,29 @@ def test_synonymize_taxon_no_target_children(self): self.assertEqual(det_plantae_1.preferredtaxon_id, plantae.id) self.assertEqual(det_plantae_2.preferredtaxon_id, plantae.id) - \ No newline at end of file + + @patch("specifyweb.backend.context.app_resource.get_app_resource") + def test_synonymize_children_allowed_when_strict_disabled(self, get_app_resource): + get_app_resource.return_value = ( + '{"treeManagement":{"strict_synonymization_checks":{"Geography": false}}}', + None, + None, + ) + + synonymize(self.kansas, self.mo, self.agent, user=None, collection=None) + self.kansas.refresh_from_db() + + self.assertEqual(self.kansas.accepted_id, self.mo.id) + + @patch("specifyweb.backend.context.app_resource.get_app_resource") + def test_synonymize_children_allowed_by_legacy_pref(self, get_app_resource): + get_app_resource.return_value = ( + '{"treeManagement":{"synonymized":{"sp7.allow_adding_child_to_synonymized_parent.Geography": true}}}', + None, + None, + ) + + synonymize(self.kansas, self.mo, self.agent, user=None, collection=None) + self.kansas.refresh_from_db() + + self.assertEqual(self.kansas.accepted_id, self.mo.id) 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 193bde74812..9a16736fd54 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 @@ -12,6 +12,7 @@ import { useSaveBlockers } from '../saveBlockers'; import { schema } from '../schema'; import type { SpecifyTable } from '../specifyTable'; import { tables } from '../tables'; +import { getStrictSynonymizationChecksPref } from '../treeBusinessRules'; import type { CollectingEvent, CollectionObjectType, @@ -21,37 +22,6 @@ import type { TaxonTreeDefItem, } from '../types'; -jest.mock('../../Preferences/collectionPreferences', () => { - let raw: any = { - treeManagement: { - strict_synonymization_checks: {}, - synonymized: {}, - }, - }; - - const get = (...path: readonly any[]): unknown => path.reduce((accumulator, key) => (accumulator == null ? accumulator : accumulator[key]), raw); - - const getRaw = (): any => raw; - - const setRaw = (next: any): void => { - raw = next; - }; - - return { - collectionPreferences: { - get, - getRaw, - setRaw, - }, - }; -}); - -overrideAjax('/context/collection_resource/', { - collectionPreferences: {}, - userPreferences: {}, - disciplinePreferences: {}, -}); - mockTime(); requireContext(); @@ -966,3 +936,67 @@ describe('treeBusinessRules', () => { } }); }); + +describe('getStrictSynonymizationChecksPref', () => { + test('defaults to strict for supported trees when no explicit preference exists', () => { + const result = getStrictSynonymizationChecksPref( + { + get: () => undefined, + getRaw: () => ({}), + }, + 'Taxon' + ); + expect(result).toBe(true); + }); + + test('uses explicit strict_synonymization_checks value', () => { + const result = getStrictSynonymizationChecksPref( + { + get: () => undefined, + getRaw: () => ({ + treeManagement: { + strict_synonymization_checks: { + Taxon: false, + }, + }, + }), + }, + 'Taxon' + ); + expect(result).toBe(false); + }); + + test('inverts legacy allow_adding_child_to_synonymized_parent value', () => { + const result = getStrictSynonymizationChecksPref( + { + get: () => undefined, + getRaw: () => ({ + treeManagement: { + synonymized: { + 'sp7.allow_adding_child_to_synonymized_parent.Taxon': true, + }, + }, + }), + }, + 'Taxon' + ); + expect(result).toBe(false); + }); + + test('matches backend key mapping for geologictimeperiod', () => { + const result = getStrictSynonymizationChecksPref( + { + get: () => undefined, + getRaw: () => ({ + treeManagement: { + strict_synonymization_checks: { + GeologicTimePeriod: false, + }, + }, + }), + }, + 'geologictimeperiod' + ); + expect(result).toBe(false); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 32ce90e55e2..d517984ec49 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -2,6 +2,7 @@ import { treeText } from '../../localization/tree'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; import { fetchPossibleRanks } from '../PickLists/TreeLevelPickList'; +import { collectionPreferences } from '../Preferences/collectionPreferences'; import { formatUrl } from '../Router/queryString'; import type { BusinessRuleResult } from './businessRules'; import type { AnyTree, TableFields } from './helperTypes'; @@ -9,11 +10,6 @@ import type { SpecifyResource } from './legacyTypes'; import { idFromUrl } from './resource'; import type { Tables } from './types'; -const getCollectionPreferences = async () => { - const mod = await import('../Preferences/collectionPreferences'); - return mod.collectionPreferences; -}; - // eslint-disable-next-line unicorn/prevent-abbreviations export type TreeDefItem = Tables[`${TREE['tableName']}TreeDefItem`]; @@ -44,7 +40,6 @@ export const treeBusinessRules = async ( idFromUrl(parentDefItem.get('treeDef'))! ); - const collectionPreferences = await getCollectionPreferences(); const strictChecksEnabled = getStrictSynonymizationChecksPref( collectionPreferences, resource.specifyTable.name @@ -151,48 +146,86 @@ const STRICT_TREES = new Set([ 'tectonicunit', ]); -const canonicalPrefKey = (tableName: string): string => - tableName.length === 0 - ? tableName - : tableName[0].toUpperCase() + tableName.slice(1).toLowerCase(); +const STRICT_TREE_KEY_MAP: Readonly> = { + taxon: 'Taxon', + geography: 'Geography', + storage: 'Storage', + geologictimeperiod: 'GeologicTimePeriod', + lithostrat: 'LithoStrat', + tectonicunit: 'TectonicUnit', +}; export function getStrictSynonymizationChecksPref( - collectionPreferences: { get: (...args: any[]) => unknown }, + collectionPreferences: { + get: (...args: any[]) => unknown; + getRaw?: () => unknown; + }, tableName: string ): boolean { + const rawPreferences = collectionPreferences.getRaw?.(); + const treeManagement = + rawPreferences !== null && + typeof rawPreferences === 'object' && + 'treeManagement' in rawPreferences + ? (rawPreferences as Record).treeManagement + : undefined; + const treeManagementDict = + treeManagement !== null && typeof treeManagement === 'object' + ? (treeManagement as Record) + : undefined; + const normalized = tableName.toLowerCase(); if (!STRICT_TREES.has(normalized)) return false; - const keyTitle = canonicalPrefKey(normalized); - const keyRaw = tableName; - - const strictValue = - (collectionPreferences.get( - 'treeManagement', - 'strict_synonymization_checks', - keyRaw - ) as unknown) ?? - (collectionPreferences.get( - 'treeManagement', - 'strict_synonymization_checks', - keyTitle - ) as unknown); - - if (typeof strictValue === 'boolean') return strictValue; - - const legacyAllow = - (collectionPreferences.get( - 'treeManagement', - 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${keyRaw}` - ) as unknown) ?? - (collectionPreferences.get( - 'treeManagement', - 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${keyTitle}` - ) as unknown); - - if (typeof legacyAllow === 'boolean') return !legacyAllow; + const keyTitle = STRICT_TREE_KEY_MAP[normalized]; + const keyCandidates = [tableName, keyTitle, normalized]; + + const strictSynonymizationChecks = treeManagementDict?.[ + 'strict_synonymization_checks' + ]; + const strictSynonymizationChecksDict = + strictSynonymizationChecks !== null && + typeof strictSynonymizationChecks === 'object' + ? (strictSynonymizationChecks as Record) + : undefined; + + for (const key of keyCandidates) { + const strictValue = strictSynonymizationChecksDict?.[key]; + if (typeof strictValue === 'boolean') return strictValue; + } + + const synonymized = treeManagementDict?.synonymized; + const synonymizedDict = + synonymized !== null && typeof synonymized === 'object' + ? (synonymized as Record) + : undefined; + for (const key of keyCandidates) { + const legacyAllow = + synonymizedDict?.[ + `sp7.allow_adding_child_to_synonymized_parent.${key}` + ]; + if (typeof legacyAllow === 'boolean') return !legacyAllow; + } + + if (typeof collectionPreferences.getRaw !== 'function') { + for (const key of keyCandidates) { + const strictValueFromGet = collectionPreferences.get( + 'treeManagement', + 'strict_synonymization_checks', + key + ) as unknown; + if (typeof strictValueFromGet === 'boolean') return strictValueFromGet; + } + + for (const key of keyCandidates) { + const legacyAllowFromGet = collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${key}` + ) as unknown; + if (typeof legacyAllowFromGet === 'boolean') return !legacyAllowFromGet; + } + } return true; } diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index 91ab50c7c32..05f48bc9907 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -69,42 +69,42 @@ export const collectionPreferenceDefinitions = { title: () => tableLabel('Taxon'), requiresReload: false, visible: true, - defaultValue: false, + defaultValue: true, type: 'java.lang.Boolean', }), Geography: definePref({ title: () => tableLabel('Geography'), requiresReload: false, visible: true, - defaultValue: false, + defaultValue: true, type: 'java.lang.Boolean', }), Storage: definePref({ title: () => tableLabel('Storage'), requiresReload: false, visible: true, - defaultValue: false, + defaultValue: true, type: 'java.lang.Boolean', }), GeologicTimePeriod: definePref({ title: () => tableLabel('GeologicTimePeriod'), requiresReload: false, visible: true, - defaultValue: false, + defaultValue: true, type: 'java.lang.Boolean', }), LithoStrat: definePref({ title: () => tableLabel('LithoStrat'), requiresReload: false, visible: true, - defaultValue: false, + defaultValue: true, type: 'java.lang.Boolean', }), TectonicUnit: definePref({ title: () => tableLabel('TectonicUnit'), requiresReload: false, visible: true, - defaultValue: false, + defaultValue: true, type: 'java.lang.Boolean', }), }, From 3df948c38dbd8109d58ad5526fbc9beeec09d586 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 14:37:47 -0600 Subject: [PATCH 31/40] Revert "add new synonymize tests" This reverts commit 90457e7d8d9a10466abfd53b36ed989db293573e. --- specifyweb/backend/trees/extras.py | 49 ++++---- .../tests/test_tree_extras/test_synonymize.py | 29 +---- .../DataModel/__tests__/businessRules.test.ts | 96 +++++---------- .../components/DataModel/treeBusinessRules.ts | 115 +++++++----------- .../Preferences/CollectionDefinitions.tsx | 12 +- 5 files changed, 100 insertions(+), 201 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 2cf56ed2099..4b217cdc84c 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -245,47 +245,40 @@ def _get_collection_prefs_dict(collection, user, *, fail_silently: bool = False) def _strict_synonymization_checks_enabled(collection, user, tree_name: str) -> bool: """ New CollectionPreferences shape: + treeManagement.expand_synonymization_actions. = true/false + New CollectionPreferences shape (opt-in strict checking): treeManagement.strict_synonymization_checks. = true/false Backward compat with legacy shape: treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false + Backward compat with legacy shape (opt-in expanded behavior): + treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false """ - prefs = _get_collection_prefs_dict(collection, user, fail_silently=True) + prefs = _get_collection_prefs_dict(collection, user) tm = prefs.get("treeManagement") or {} if not isinstance(tm, dict): - tm = {} - - strict_tree_key_map = { - "taxon": "Taxon", - "geography": "Geography", - "storage": "Storage", - "geologictimeperiod": "GeologicTimePeriod", - "lithostrat": "LithoStrat", - "tectonicunit": "TectonicUnit", - } - normalized_name = tree_name.lower() - title_name = strict_tree_key_map.get(normalized_name) - if title_name is None: return False - - key_candidates = [tree_name, title_name, normalized_name] - + + # New shape + esa = tm.get("expand_synonymization_actions") + if isinstance(esa, dict) and tree_name in esa: + return bool(esa.get(tree_name)) strict = tm.get("strict_synonymization_checks") - if isinstance(strict, dict): - for key in key_candidates: - if key in strict and isinstance(strict.get(key), bool): - return strict[key] + if isinstance(strict, dict) and tree_name in strict: + return bool(strict.get(tree_name)) - # Legacy shape "allow expanded behavior" + # Legacy shape syn = tm.get("synonymized") if isinstance(syn, dict): - for key in key_candidates: - legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{key}" - if legacy_key in syn and isinstance(syn.get(legacy_key), bool): - return not syn[legacy_key] + legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{tree_name}" + if legacy_key in syn: + return bool(syn.get(legacy_key)) + legacy_allow_expand = bool(syn.get(legacy_key)) + return not legacy_allow_expand - return True + # Default if nothing set + return False def adding_node(node, collection=None, user=None): logger.info('adding node %s', node) @@ -900,4 +893,4 @@ def tree_path_expr(tbl: str, d: int) -> str: # replace path_expr if ordering iss from specifyweb.specify.models import datamodel, Sptasksemaphore tree_model = datamodel.get_table(table) tasknames = [name.format(tree_model.name) for name in ("UpdateNodes{}", "BadNodes{}")] - Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) + Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) \ No newline at end of file diff --git a/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py b/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py index 10fca108130..ef724e0c7fe 100644 --- a/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py +++ b/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException from specifyweb.specify.models import Determination, Taxon, Taxontreedef from specifyweb.backend.trees.tests.test_trees import GeographyTree @@ -143,29 +141,4 @@ def test_synonymize_taxon_no_target_children(self): self.assertEqual(det_plantae_1.preferredtaxon_id, plantae.id) self.assertEqual(det_plantae_2.preferredtaxon_id, plantae.id) - - @patch("specifyweb.backend.context.app_resource.get_app_resource") - def test_synonymize_children_allowed_when_strict_disabled(self, get_app_resource): - get_app_resource.return_value = ( - '{"treeManagement":{"strict_synonymization_checks":{"Geography": false}}}', - None, - None, - ) - - synonymize(self.kansas, self.mo, self.agent, user=None, collection=None) - self.kansas.refresh_from_db() - - self.assertEqual(self.kansas.accepted_id, self.mo.id) - - @patch("specifyweb.backend.context.app_resource.get_app_resource") - def test_synonymize_children_allowed_by_legacy_pref(self, get_app_resource): - get_app_resource.return_value = ( - '{"treeManagement":{"synonymized":{"sp7.allow_adding_child_to_synonymized_parent.Geography": true}}}', - None, - None, - ) - - synonymize(self.kansas, self.mo, self.agent, user=None, collection=None) - self.kansas.refresh_from_db() - - self.assertEqual(self.kansas.accepted_id, self.mo.id) + \ No newline at end of file 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 9a16736fd54..193bde74812 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 @@ -12,7 +12,6 @@ import { useSaveBlockers } from '../saveBlockers'; import { schema } from '../schema'; import type { SpecifyTable } from '../specifyTable'; import { tables } from '../tables'; -import { getStrictSynonymizationChecksPref } from '../treeBusinessRules'; import type { CollectingEvent, CollectionObjectType, @@ -22,6 +21,37 @@ import type { TaxonTreeDefItem, } from '../types'; +jest.mock('../../Preferences/collectionPreferences', () => { + let raw: any = { + treeManagement: { + strict_synonymization_checks: {}, + synonymized: {}, + }, + }; + + const get = (...path: readonly any[]): unknown => path.reduce((accumulator, key) => (accumulator == null ? accumulator : accumulator[key]), raw); + + const getRaw = (): any => raw; + + const setRaw = (next: any): void => { + raw = next; + }; + + return { + collectionPreferences: { + get, + getRaw, + setRaw, + }, + }; +}); + +overrideAjax('/context/collection_resource/', { + collectionPreferences: {}, + userPreferences: {}, + disciplinePreferences: {}, +}); + mockTime(); requireContext(); @@ -936,67 +966,3 @@ describe('treeBusinessRules', () => { } }); }); - -describe('getStrictSynonymizationChecksPref', () => { - test('defaults to strict for supported trees when no explicit preference exists', () => { - const result = getStrictSynonymizationChecksPref( - { - get: () => undefined, - getRaw: () => ({}), - }, - 'Taxon' - ); - expect(result).toBe(true); - }); - - test('uses explicit strict_synonymization_checks value', () => { - const result = getStrictSynonymizationChecksPref( - { - get: () => undefined, - getRaw: () => ({ - treeManagement: { - strict_synonymization_checks: { - Taxon: false, - }, - }, - }), - }, - 'Taxon' - ); - expect(result).toBe(false); - }); - - test('inverts legacy allow_adding_child_to_synonymized_parent value', () => { - const result = getStrictSynonymizationChecksPref( - { - get: () => undefined, - getRaw: () => ({ - treeManagement: { - synonymized: { - 'sp7.allow_adding_child_to_synonymized_parent.Taxon': true, - }, - }, - }), - }, - 'Taxon' - ); - expect(result).toBe(false); - }); - - test('matches backend key mapping for geologictimeperiod', () => { - const result = getStrictSynonymizationChecksPref( - { - get: () => undefined, - getRaw: () => ({ - treeManagement: { - strict_synonymization_checks: { - GeologicTimePeriod: false, - }, - }, - }), - }, - 'geologictimeperiod' - ); - expect(result).toBe(false); - }); -}); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index d517984ec49..32ce90e55e2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -2,7 +2,6 @@ import { treeText } from '../../localization/tree'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; import { fetchPossibleRanks } from '../PickLists/TreeLevelPickList'; -import { collectionPreferences } from '../Preferences/collectionPreferences'; import { formatUrl } from '../Router/queryString'; import type { BusinessRuleResult } from './businessRules'; import type { AnyTree, TableFields } from './helperTypes'; @@ -10,6 +9,11 @@ import type { SpecifyResource } from './legacyTypes'; import { idFromUrl } from './resource'; import type { Tables } from './types'; +const getCollectionPreferences = async () => { + const mod = await import('../Preferences/collectionPreferences'); + return mod.collectionPreferences; +}; + // eslint-disable-next-line unicorn/prevent-abbreviations export type TreeDefItem = Tables[`${TREE['tableName']}TreeDefItem`]; @@ -40,6 +44,7 @@ export const treeBusinessRules = async ( idFromUrl(parentDefItem.get('treeDef'))! ); + const collectionPreferences = await getCollectionPreferences(); const strictChecksEnabled = getStrictSynonymizationChecksPref( collectionPreferences, resource.specifyTable.name @@ -146,86 +151,48 @@ const STRICT_TREES = new Set([ 'tectonicunit', ]); -const STRICT_TREE_KEY_MAP: Readonly> = { - taxon: 'Taxon', - geography: 'Geography', - storage: 'Storage', - geologictimeperiod: 'GeologicTimePeriod', - lithostrat: 'LithoStrat', - tectonicunit: 'TectonicUnit', -}; +const canonicalPrefKey = (tableName: string): string => + tableName.length === 0 + ? tableName + : tableName[0].toUpperCase() + tableName.slice(1).toLowerCase(); export function getStrictSynonymizationChecksPref( - collectionPreferences: { - get: (...args: any[]) => unknown; - getRaw?: () => unknown; - }, + collectionPreferences: { get: (...args: any[]) => unknown }, tableName: string ): boolean { - const rawPreferences = collectionPreferences.getRaw?.(); - const treeManagement = - rawPreferences !== null && - typeof rawPreferences === 'object' && - 'treeManagement' in rawPreferences - ? (rawPreferences as Record).treeManagement - : undefined; - const treeManagementDict = - treeManagement !== null && typeof treeManagement === 'object' - ? (treeManagement as Record) - : undefined; - const normalized = tableName.toLowerCase(); if (!STRICT_TREES.has(normalized)) return false; - const keyTitle = STRICT_TREE_KEY_MAP[normalized]; - const keyCandidates = [tableName, keyTitle, normalized]; - - const strictSynonymizationChecks = treeManagementDict?.[ - 'strict_synonymization_checks' - ]; - const strictSynonymizationChecksDict = - strictSynonymizationChecks !== null && - typeof strictSynonymizationChecks === 'object' - ? (strictSynonymizationChecks as Record) - : undefined; - - for (const key of keyCandidates) { - const strictValue = strictSynonymizationChecksDict?.[key]; - if (typeof strictValue === 'boolean') return strictValue; - } - - const synonymized = treeManagementDict?.synonymized; - const synonymizedDict = - synonymized !== null && typeof synonymized === 'object' - ? (synonymized as Record) - : undefined; - for (const key of keyCandidates) { - const legacyAllow = - synonymizedDict?.[ - `sp7.allow_adding_child_to_synonymized_parent.${key}` - ]; - if (typeof legacyAllow === 'boolean') return !legacyAllow; - } - - if (typeof collectionPreferences.getRaw !== 'function') { - for (const key of keyCandidates) { - const strictValueFromGet = collectionPreferences.get( - 'treeManagement', - 'strict_synonymization_checks', - key - ) as unknown; - if (typeof strictValueFromGet === 'boolean') return strictValueFromGet; - } - - for (const key of keyCandidates) { - const legacyAllowFromGet = collectionPreferences.get( - 'treeManagement', - 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${key}` - ) as unknown; - if (typeof legacyAllowFromGet === 'boolean') return !legacyAllowFromGet; - } - } + const keyTitle = canonicalPrefKey(normalized); + const keyRaw = tableName; + + const strictValue = + (collectionPreferences.get( + 'treeManagement', + 'strict_synonymization_checks', + keyRaw + ) as unknown) ?? + (collectionPreferences.get( + 'treeManagement', + 'strict_synonymization_checks', + keyTitle + ) as unknown); + + if (typeof strictValue === 'boolean') return strictValue; + + const legacyAllow = + (collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${keyRaw}` + ) as unknown) ?? + (collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${keyTitle}` + ) as unknown); + + if (typeof legacyAllow === 'boolean') return !legacyAllow; return true; } diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index 05f48bc9907..91ab50c7c32 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -69,42 +69,42 @@ export const collectionPreferenceDefinitions = { title: () => tableLabel('Taxon'), requiresReload: false, visible: true, - defaultValue: true, + defaultValue: false, type: 'java.lang.Boolean', }), Geography: definePref({ title: () => tableLabel('Geography'), requiresReload: false, visible: true, - defaultValue: true, + defaultValue: false, type: 'java.lang.Boolean', }), Storage: definePref({ title: () => tableLabel('Storage'), requiresReload: false, visible: true, - defaultValue: true, + defaultValue: false, type: 'java.lang.Boolean', }), GeologicTimePeriod: definePref({ title: () => tableLabel('GeologicTimePeriod'), requiresReload: false, visible: true, - defaultValue: true, + defaultValue: false, type: 'java.lang.Boolean', }), LithoStrat: definePref({ title: () => tableLabel('LithoStrat'), requiresReload: false, visible: true, - defaultValue: true, + defaultValue: false, type: 'java.lang.Boolean', }), TectonicUnit: definePref({ title: () => tableLabel('TectonicUnit'), requiresReload: false, visible: true, - defaultValue: true, + defaultValue: false, type: 'java.lang.Boolean', }), }, From 7bcd95206607d7648c12673460eec1c4a7c58552 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 20:41:53 +0000 Subject: [PATCH 32/40] Lint code with ESLint and Prettier Triggered by 3df948c38dbd8109d58ad5526fbc9beeec09d586 on branch refs/heads/issue-7662 --- .../components/DataModel/treeBusinessRules.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 32ce90e55e2..2768b5266ec 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -10,8 +10,8 @@ import { idFromUrl } from './resource'; import type { Tables } from './types'; const getCollectionPreferences = async () => { - const mod = await import('../Preferences/collectionPreferences'); - return mod.collectionPreferences; + const module_ = await import('../Preferences/collectionPreferences'); + return module_.collectionPreferences; }; // eslint-disable-next-line unicorn/prevent-abbreviations @@ -157,7 +157,7 @@ const canonicalPrefKey = (tableName: string): string => : tableName[0].toUpperCase() + tableName.slice(1).toLowerCase(); export function getStrictSynonymizationChecksPref( - collectionPreferences: { get: (...args: any[]) => unknown }, + collectionPreferences: { readonly get: (...args: readonly any[]) => unknown }, tableName: string ): boolean { const normalized = tableName.toLowerCase(); @@ -167,30 +167,30 @@ export function getStrictSynonymizationChecksPref( const keyRaw = tableName; const strictValue = - (collectionPreferences.get( + collectionPreferences.get( 'treeManagement', 'strict_synonymization_checks', keyRaw - ) as unknown) ?? - (collectionPreferences.get( + ) ?? + collectionPreferences.get( 'treeManagement', 'strict_synonymization_checks', keyTitle - ) as unknown); + ); if (typeof strictValue === 'boolean') return strictValue; const legacyAllow = - (collectionPreferences.get( + collectionPreferences.get( 'treeManagement', 'synonymized', `sp7.allow_adding_child_to_synonymized_parent.${keyRaw}` - ) as unknown) ?? - (collectionPreferences.get( + ) ?? + collectionPreferences.get( 'treeManagement', 'synonymized', `sp7.allow_adding_child_to_synonymized_parent.${keyTitle}` - ) as unknown); + ); if (typeof legacyAllow === 'boolean') return !legacyAllow; From b2fea781cf64f3e7cb56a361080a2ddd4000b113 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 15:51:30 -0600 Subject: [PATCH 33/40] simplify tree extras --- .../tests/test_upload_locality_set.py | 1 + specifyweb/backend/trees/extras.py | 126 +++++++----------- .../DataModel/__tests__/businessRules.test.ts | 86 +++--------- .../components/DataModel/treeBusinessRules.ts | 74 ++-------- .../lib/components/Errors/stackTrace.ts | 17 +-- .../Preferences/CollectionDefinitions.tsx | 52 +------- .../lib/components/TreeView/Actions.tsx | 17 ++- 7 files changed, 93 insertions(+), 280 deletions(-) diff --git a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py index 950525d05ef..2fce2e07d2c 100644 --- a/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py +++ b/specifyweb/backend/locality_update_tool/tests/test_upload_locality_set.py @@ -1,6 +1,7 @@ from specifyweb.backend.locality_update_tool.tests.test_update_locality_context import ( TestUpdateLocalityContext, ) +from specifyweb.backend.locality_update_tool.update_locality import upload_locality_set class TestUploadLocalitySet(TestUpdateLocalityContext): diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 4b217cdc84c..3b1a6d29656 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -207,89 +207,55 @@ def close_interval(model, node_number, size): highestchildnodenumber=F('highestchildnodenumber')-size, ) -def _get_collection_prefs_dict(collection, user, *, fail_silently: bool = False) -> dict: +def _get_collection_preferences(collection=None, user=None): import specifyweb.backend.context.app_resource as app_resource - res = app_resource.get_app_resource(collection, user, 'CollectionPreferences') - if not res: + try: + resource = app_resource.get_app_resource(collection, user, 'CollectionPreferences') + except Exception as e: + logger.warning('Could not load CollectionPreferences: %s', e) + return {} + + if resource is None: return {} - collection_prefs_json, _, __ = res - if not collection_prefs_json: + if not isinstance(resource, (tuple, list)) or len(resource) == 0: + logger.warning('Unexpected CollectionPreferences resource format: %r', type(resource)) + return {} + + collection_prefs_json = resource[0] + if collection_prefs_json is None: return {} try: - loaded = json.loads(collection_prefs_json) - except json.JSONDecodeError as e: - logger.warning( - "Failed to parse CollectionPreferences JSON; collection_id=%s user_id=%s error=%s", - getattr(collection, "id", None), - getattr(user, "id", None), - str(e), - ) - if fail_silently: - return {} - raise - except Exception: - logger.exception( - "Unexpected error while loading CollectionPreferences; collection_id=%s user_id=%s", - getattr(collection, "id", None), - getattr(user, "id", None), - ) - if fail_silently: - return {} - raise + collection_prefs_dict = json.loads(collection_prefs_json) + except (TypeError, json.JSONDecodeError) as e: + logger.warning('Could not parse CollectionPreferences JSON: %s', e) + return {} - return loaded if isinstance(loaded, dict) else {} + return collection_prefs_dict if isinstance(collection_prefs_dict, dict) else {} -def _strict_synonymization_checks_enabled(collection, user, tree_name: str) -> bool: - """ - New CollectionPreferences shape: - treeManagement.expand_synonymization_actions. = true/false - New CollectionPreferences shape (opt-in strict checking): - treeManagement.strict_synonymization_checks. = true/false - - Backward compat with legacy shape: - treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false - Backward compat with legacy shape (opt-in expanded behavior): - treeManagement.synonymized["sp7.allow_adding_child_to_synonymized_parent."] = true/false - """ - prefs = _get_collection_prefs_dict(collection, user) - - tm = prefs.get("treeManagement") or {} - if not isinstance(tm, dict): +def _allow_adding_child_to_synonymized_parent(node, collection=None, user=None): + collection_prefs_dict = _get_collection_preferences(collection, user) + tree_management_pref = collection_prefs_dict.get('treeManagement', {}) + + synonymized = tree_management_pref.get('synonymized', {}) \ + if isinstance(tree_management_pref, dict) else {} + + if not isinstance(synonymized, dict): return False - - # New shape - esa = tm.get("expand_synonymization_actions") - if isinstance(esa, dict) and tree_name in esa: - return bool(esa.get(tree_name)) - strict = tm.get("strict_synonymization_checks") - if isinstance(strict, dict) and tree_name in strict: - return bool(strict.get(tree_name)) - - # Legacy shape - syn = tm.get("synonymized") - if isinstance(syn, dict): - legacy_key = f"sp7.allow_adding_child_to_synonymized_parent.{tree_name}" - if legacy_key in syn: - return bool(syn.get(legacy_key)) - legacy_allow_expand = bool(syn.get(legacy_key)) - return not legacy_allow_expand - - # Default if nothing set - return False - -def adding_node(node, collection=None, user=None): + + pref_key = f'sp7.allow_adding_child_to_synonymized_parent.{node.specify_model.name}' + return synonymized.get(pref_key, False) is True + +def adding_node(node,collection=None, user=None): logger.info('adding node %s', node) model = type(node) parent = model.objects.select_for_update().get(id=node.parent.id) if parent.accepted_id is not None: - tree_name = node.specify_model.name - strict_checks = _strict_synonymization_checks_enabled(collection, user, tree_name) - - if strict_checks: + allow_adding_child = _allow_adding_child_to_synonymized_parent(node, collection, user) + if not allow_adding_child: raise TreeBusinessRuleException( f'Adding node "{node.fullname}" to synonymized parent "{parent.fullname}"', {"tree" : "Taxon", @@ -309,6 +275,7 @@ def adding_node(node, collection=None, user=None): "parentid": parent.parent.id, "children": list(parent.children.values('id', 'fullname')) }}) + insertion_point = open_interval(model, parent.nodenumber, 1) node.highestchildnodenumber = node.nodenumber = insertion_point @@ -468,31 +435,30 @@ def synonymize(node, into, agent, user=None, collection=None): "parentid": into.parent.id, "children": list(into.children.values('id', 'fullname')) }}) - tree_name = node.specify_model.name - strict_checks = _strict_synonymization_checks_enabled(collection, user, tree_name) + node.accepted_id = target.id + node.isaccepted = False + node.save() - if strict_checks and node.children.exists(): + # This check can be disabled by a remote pref + allow_adding_child = _allow_adding_child_to_synonymized_parent(node, collection, user) + if node.children.count() > 0 and not allow_adding_child: raise TreeBusinessRuleException( f'Synonymizing node "{node.fullname}" which has children', {"tree" : "Taxon", - "localizationKey" : "nodeSynonimizeWithChildren", - "node" : { + "localizationKey" : "nodeSynonimizeWithChildren", + "node" : { "id" : node.id, "rankid" : node.rankid, "fullName" : node.fullname, "children": list(node.children.values('id', 'fullname')) - }, - "parent" : { + }, + "parent" : { "id" : into.id, "rankid" : into.rankid, "fullName" : into.fullname, "parentid": into.parent.id, "children": list(into.children.values('id', 'fullname')) - }}) - - node.accepted_id = target.id - node.isaccepted = False - node.save() + }}) node.acceptedchildren.update(**{node.accepted_id_attr().replace('_id', ''): target}) #assuming synonym can't be synonymized field_change_infos = [ 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 193bde74812..9aefa5b0b00 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 @@ -21,37 +21,6 @@ import type { TaxonTreeDefItem, } from '../types'; -jest.mock('../../Preferences/collectionPreferences', () => { - let raw: any = { - treeManagement: { - strict_synonymization_checks: {}, - synonymized: {}, - }, - }; - - const get = (...path: readonly any[]): unknown => path.reduce((accumulator, key) => (accumulator == null ? accumulator : accumulator[key]), raw); - - const getRaw = (): any => raw; - - const setRaw = (next: any): void => { - raw = next; - }; - - return { - collectionPreferences: { - get, - getRaw, - setRaw, - }, - }; -}); - -overrideAjax('/context/collection_resource/', { - collectionPreferences: {}, - userPreferences: {}, - disciplinePreferences: {}, -}); - mockTime(); requireContext(); @@ -877,7 +846,6 @@ describe('treeBusinessRules', () => { }); await taxon.businessRuleManager?.checkField('parent'); - await taxon.businessRuleManager?.pendingPromise; const { result } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -895,7 +863,6 @@ describe('treeBusinessRules', () => { }); await taxon.businessRuleManager?.checkField('parent'); - await taxon.businessRuleManager?.pendingPromise; const { result } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -912,7 +879,6 @@ describe('treeBusinessRules', () => { }); await taxon.businessRuleManager?.checkField('parent'); - await taxon.businessRuleManager?.pendingPromise; const { result } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -920,7 +886,6 @@ describe('treeBusinessRules', () => { expect(result.current[0]).toStrictEqual(['Bad tree structure.']); await taxon.businessRuleManager?.checkField('integer1'); - await taxon.businessRuleManager?.pendingPromise; const { result: fieldChangeResult } = renderHook(() => useSaveBlockers(taxon, tables.Taxon.getField('parent')) @@ -931,38 +896,31 @@ describe('treeBusinessRules', () => { const { collectionPreferences } = await import( '../../Preferences/collectionPreferences' ); - const originalRaw = collectionPreferences.getRaw(); - - try { - collectionPreferences.setRaw({ - ...originalRaw, - treeManagement: { - ...originalRaw.treeManagement, - strict_synonymization_checks: { - ...(originalRaw.treeManagement?.strict_synonymization_checks), - Taxon: false, - }, + collectionPreferences.setRaw({ + ...originalRaw, + treeManagement: { + ...originalRaw.treeManagement, + synonymized: { + ...originalRaw.treeManagement?.synonymized, + 'sp7.allow_adding_child_to_synonymized_parent.Taxon': true, }, - }); + }, + } as typeof originalRaw); - const taxon = new tables.Taxon.Resource({ - name: 'dauricus', - parent: '/api/specify/taxon/6/', - rankId: 220, - definition: '/api/specify/taxontreedef/1/', - definitionItem: '/api/specify/taxontreedefitem/2/', - }); + const taxon = new tables.Taxon.Resource({ + name: 'dauricus', + parent: '/api/specify/taxon/6/', + rankId: 220, + definition: '/api/specify/taxontreedef/1/', + definitionItem: '/api/specify/taxontreedefitem/2/', + }); - await taxon.businessRuleManager?.checkField('parent'); - await taxon.businessRuleManager?.pendingPromise; + await taxon.businessRuleManager?.checkField('parent'); - const { result } = renderHook(() => - useSaveBlockers(taxon, tables.Taxon.getField('parent')) - ); - expect(result.current[0]).toStrictEqual([]); - } finally { - collectionPreferences.setRaw(originalRaw); - } + const { result } = renderHook(() => + useSaveBlockers(taxon, tables.Taxon.getField('parent')) + ); + expect(result.current[0]).toStrictEqual([]); }); -}); +}); \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 2768b5266ec..f12edad96a5 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -9,11 +9,6 @@ import type { SpecifyResource } from './legacyTypes'; import { idFromUrl } from './resource'; import type { Tables } from './types'; -const getCollectionPreferences = async () => { - const module_ = await import('../Preferences/collectionPreferences'); - return module_.collectionPreferences; -}; - // eslint-disable-next-line unicorn/prevent-abbreviations export type TreeDefItem = Tables[`${TREE['tableName']}TreeDefItem`]; @@ -44,10 +39,12 @@ export const treeBusinessRules = async ( idFromUrl(parentDefItem.get('treeDef'))! ); - const collectionPreferences = await getCollectionPreferences(); - const strictChecksEnabled = getStrictSynonymizationChecksPref( - collectionPreferences, - resource.specifyTable.name + const prefModule = await import('../Preferences/collectionPreferences'); + const collectionPreferences = prefModule.collectionPreferences; + const doExpandSynonymActionsPref = collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${resource.specifyTable.name}` ); const isParentSynonym = !parent.get('isAccepted'); @@ -55,7 +52,7 @@ export const treeBusinessRules = async ( const hasBadTreeStrcuture = parent.id === resource.id || definitionItem === undefined || - (isParentSynonym && strictChecksEnabled) || + (isParentSynonym && !doExpandSynonymActionsPref) || parent.get('rankId') >= definitionItem.get('rankId') || (possibleRanks !== undefined && !possibleRanks @@ -140,59 +137,4 @@ const predictFullName = async < { headers: { Accept: 'text/plain' }, } - ).then(({ data }) => data); - -const STRICT_TREES = new Set([ - 'taxon', - 'geography', - 'storage', - 'geologictimeperiod', - 'lithostrat', - 'tectonicunit', -]); - -const canonicalPrefKey = (tableName: string): string => - tableName.length === 0 - ? tableName - : tableName[0].toUpperCase() + tableName.slice(1).toLowerCase(); - -export function getStrictSynonymizationChecksPref( - collectionPreferences: { readonly get: (...args: readonly any[]) => unknown }, - tableName: string -): boolean { - const normalized = tableName.toLowerCase(); - if (!STRICT_TREES.has(normalized)) return false; - - const keyTitle = canonicalPrefKey(normalized); - const keyRaw = tableName; - - const strictValue = - collectionPreferences.get( - 'treeManagement', - 'strict_synonymization_checks', - keyRaw - ) ?? - collectionPreferences.get( - 'treeManagement', - 'strict_synonymization_checks', - keyTitle - ); - - if (typeof strictValue === 'boolean') return strictValue; - - const legacyAllow = - collectionPreferences.get( - 'treeManagement', - 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${keyRaw}` - ) ?? - collectionPreferences.get( - 'treeManagement', - 'synonymized', - `sp7.allow_adding_child_to_synonymized_parent.${keyTitle}` - ); - - if (typeof legacyAllow === 'boolean') return !legacyAllow; - - return true; -} + ).then(({ data }) => data); \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts b/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts index 84fcc4d011b..c01f7671e95 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts +++ b/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts @@ -3,16 +3,6 @@ import type { R } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { consoleLog } from './interceptLogs'; -const resolvePreferences = async (preferences: { - readonly fetch?: () => Promise; - readonly getRaw?: () => unknown; -}): Promise => { - if (typeof preferences.fetch === 'function') await preferences.fetch(); - return typeof preferences.getRaw === 'function' - ? preferences.getRaw() - : undefined; -}; - const resolvedStackTrace: R = {}; Promise.all( Object.entries({ @@ -35,11 +25,12 @@ Promise.all( async ({ fetchContext }) => fetchContext ), userPreferences: import('../Preferences/userPreferences').then( - async ({ userPreferences }) => resolvePreferences(userPreferences) + async ({ userPreferences }) => + userPreferences.fetch().then(() => userPreferences.getRaw()) ), collectionPreferences: import('../Preferences/collectionPreferences').then( async ({ collectionPreferences }) => - resolvePreferences(collectionPreferences) + collectionPreferences.fetch().then(() => collectionPreferences.getRaw()) ), userInformation: import('../InitialContext/userInformation').then( async ({ fetchContext }) => fetchContext @@ -92,4 +83,4 @@ const errorSorted = [ 'errorContext', 'navigator', 'consoleLog', -]; +]; \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index 91ab50c7c32..85f9342679c 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -61,54 +61,6 @@ export const collectionPreferenceDefinitions = { treeManagement: { title: treeText.treeManagement(), subCategories: { - strict_synonymization_checks: { - title: treeText.synonymizedNodes(), - description: treeText.synonymizedNodesDescription(), - items: { - Taxon: definePref({ - title: () => tableLabel('Taxon'), - requiresReload: false, - visible: true, - defaultValue: false, - type: 'java.lang.Boolean', - }), - Geography: definePref({ - title: () => tableLabel('Geography'), - requiresReload: false, - visible: true, - defaultValue: false, - type: 'java.lang.Boolean', - }), - Storage: definePref({ - title: () => tableLabel('Storage'), - requiresReload: false, - visible: true, - defaultValue: false, - type: 'java.lang.Boolean', - }), - GeologicTimePeriod: definePref({ - title: () => tableLabel('GeologicTimePeriod'), - requiresReload: false, - visible: true, - defaultValue: false, - type: 'java.lang.Boolean', - }), - LithoStrat: definePref({ - title: () => tableLabel('LithoStrat'), - requiresReload: false, - visible: true, - defaultValue: false, - type: 'java.lang.Boolean', - }), - TectonicUnit: definePref({ - title: () => tableLabel('TectonicUnit'), - requiresReload: false, - visible: true, - defaultValue: false, - type: 'java.lang.Boolean', - }), - }, - }, synonymized: { title: treeText.synonymizedNodes(), description: treeText.synonymizedNodesDescription(), @@ -117,7 +69,7 @@ export const collectionPreferenceDefinitions = { definePref({ title: () => tableLabel('Taxon'), requiresReload: false, - visible: false, + visible: true, defaultValue: false, type: 'java.lang.Boolean', }), @@ -358,4 +310,4 @@ export const collectionPreferenceDefinitions = { }, } as const; -ensure()(collectionPreferenceDefinitions); +ensure()(collectionPreferenceDefinitions); \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index 23dc8a2178d..e6f8a1a6820 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -23,7 +23,6 @@ import { hasPermission, hasTablePermission } from '../Permissions/helpers'; import { collectionPreferences } from '../Preferences/collectionPreferences'; import type { Row } from './helpers'; import { checkMoveViolatesEnforced } from './helpers'; -import { getStrictSynonymizationChecksPref } from '../DataModel/treeBusinessRules'; const treeActions = [ 'add', @@ -70,9 +69,10 @@ export function TreeViewActions({ const resourceName = `/tree/edit/${toLowerCase(tableName)}` as const; const isSynonym = typeof focusedRow?.acceptedId === 'number'; - const strictChecksEnabled = getStrictSynonymizationChecksPref( - collectionPreferences, - tableName + const doExpandSynonymActionsPref = collectionPreferences.get( + 'treeManagement', + 'synonymized', + `sp7.allow_adding_child_to_synonymized_parent.${tableName}` ); const disableButtons = @@ -145,7 +145,8 @@ export function TreeViewActions({ addNew disabled={ focusedRow === undefined || - (isSynonym && strictChecksEnabled) || + (doExpandSynonymActionsPref ? false : isSynonym) || + // Forbid adding children to the lowest rank ranks.at(-1) === focusedRow.rankId } isRoot={false} @@ -197,7 +198,9 @@ export function TreeViewActions({ disableButtons || isReadOnly || isRoot || - (strictChecksEnabled && !isSynonym && focusedRow.children > 0) + (doExpandSynonymActionsPref + ? false + : !isSynonym && focusedRow.children > 0) } icon={isSynonym ? 'undoSynonym' : 'synonym'} title={isSynonym ? treeText.undoSynonymy() : treeText.synonymize()} @@ -512,4 +515,4 @@ function NodeDeleteButton({ onDeleted={handleDeleted} /> ); -} +} \ No newline at end of file From ba1e0643db7fc8be7057eeedc6fb7b6338746ec0 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 15:52:37 -0600 Subject: [PATCH 34/40] Update update_locality.py --- specifyweb/backend/locality_update_tool/update_locality.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/locality_update_tool/update_locality.py b/specifyweb/backend/locality_update_tool/update_locality.py index 8609d3248bc..ae1873f1631 100644 --- a/specifyweb/backend/locality_update_tool/update_locality.py +++ b/specifyweb/backend/locality_update_tool/update_locality.py @@ -514,4 +514,4 @@ def create_localityupdate_recordset(collection, specifyuser, locality_ids: list[ recordset=rs ) - return rs \ No newline at end of file + return rs From c296d32057d9199618a7ea3c00f7485eee19cf8c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 15:53:56 -0600 Subject: [PATCH 35/40] Update Actions.tsx --- specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index e6f8a1a6820..28c3c8c7faf 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -515,4 +515,4 @@ function NodeDeleteButton({ onDeleted={handleDeleted} /> ); -} \ No newline at end of file +} From c93c3b1dc984da5a608564bf32704f711c93c1f3 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 15:54:27 -0600 Subject: [PATCH 36/40] Update stackTrace.ts --- specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts b/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts index c01f7671e95..bcd2c11d874 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts +++ b/specifyweb/frontend/js_src/lib/components/Errors/stackTrace.ts @@ -83,4 +83,4 @@ const errorSorted = [ 'errorContext', 'navigator', 'consoleLog', -]; \ No newline at end of file +]; From 77cfeff8b2a2fb47b1e0070e92c072e5aeec9477 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 15:55:03 -0600 Subject: [PATCH 37/40] Update treeBusinessRules.ts --- .../js_src/lib/components/DataModel/treeBusinessRules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index f12edad96a5..1e9da65a559 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -137,4 +137,4 @@ const predictFullName = async < { headers: { Accept: 'text/plain' }, } - ).then(({ data }) => data); \ No newline at end of file + ).then(({ data }) => data); From 8b327c0f4aca16c818fc9d283ddb9d89649f9862 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 15:55:31 -0600 Subject: [PATCH 38/40] Update CollectionDefinitions.tsx --- .../js_src/lib/components/Preferences/CollectionDefinitions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index 85f9342679c..80093e9721c 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -310,4 +310,4 @@ export const collectionPreferenceDefinitions = { }, } as const; -ensure()(collectionPreferenceDefinitions); \ No newline at end of file +ensure()(collectionPreferenceDefinitions); From 1cb34ed522385381686f6a5e2d651a622a530e8c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 15:56:06 -0600 Subject: [PATCH 39/40] Update .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 961437150ca..c392ed07bab 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ seed-database /specifyweb/settings/debug.py /specifyweb/settings/secret_key.py /specifyweb/settings/ldap_settings.py -Dockerfile_Me /.mypy_cache /specifyweb/frontend/locale/**/*.mo /specifyweb/settings/build_version.py From d16fa7b258f9aed9390e2e43698b1c7bdf7df340 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 10 Feb 2026 16:08:39 -0600 Subject: [PATCH 40/40] Update businessRules.test.ts --- .../lib/components/DataModel/__tests__/businessRules.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9aefa5b0b00..26226f396f9 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 @@ -923,4 +923,4 @@ describe('treeBusinessRules', () => { ); expect(result.current[0]).toStrictEqual([]); }); -}); \ No newline at end of file +});