Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ecbdbf9
main unit test fixes
acwhite211 Jan 16, 2026
51382bd
Merge branch 'main' into issue-7662
melton-jason Jan 30, 2026
45f6960
fix: use correct key when accessing tree collection preference
melton-jason Feb 2, 2026
4806a8d
Merge branch 'main' into issue-7662
melton-jason Feb 2, 2026
af74472
add _get_collection_prefs_dict helper function
acwhite211 Feb 2, 2026
c7ed248
trigger test
acwhite211 Feb 3, 2026
6b372ee
add _expand_synonymization_actions_enabled
acwhite211 Feb 3, 2026
7306127
Merge branch 'main' into issue-7662
acwhite211 Feb 4, 2026
b87f4da
fix: broken imports in locality update tests
melton-jason Feb 6, 2026
9e2bd11
raise error in exception handling
acwhite211 Feb 9, 2026
6e75ef6
fix code duplicaiton
acwhite211 Feb 9, 2026
32f2e2f
revert update locality changes
acwhite211 Feb 9, 2026
da5b905
remove old code segment
acwhite211 Feb 9, 2026
0d769bc
change default behavior for _strict_synonymization_checks_enabled
acwhite211 Feb 9, 2026
a4280ff
Merge branch 'main' into issue-7662
acwhite211 Feb 9, 2026
234c33f
_parse_locality_set path fix
acwhite211 Feb 9, 2026
46ddbd7
fix more patch imports
acwhite211 Feb 9, 2026
7610aae
other patch import fixes
acwhite211 Feb 9, 2026
72a3371
init front-end data shape changes for tree management
acwhite211 Feb 9, 2026
e481d38
CollectionDefinition syntax fix
acwhite211 Feb 10, 2026
98b2115
fix front-end typeschecks
acwhite211 Feb 10, 2026
2bdd3c0
fix getStrictSynonymizationChecksPref
acwhite211 Feb 10, 2026
deed0ee
export function getStrictSynonymizationChecksPref
acwhite211 Feb 10, 2026
c4cd087
fix businessRules.test.ts
acwhite211 Feb 10, 2026
8ae1f74
jest ajaxMock error fixes
acwhite211 Feb 10, 2026
2b0f15d
redo getStrictSynonymizationChecksPref implementation
acwhite211 Feb 10, 2026
d3ac97e
circular import fixes in treeBusinessRules
acwhite211 Feb 10, 2026
a1b9d72
try jest.mock
acwhite211 Feb 10, 2026
d9c793e
collectionPreferences import change
acwhite211 Feb 10, 2026
fa3ac7b
Lint code with ESLint and Prettier
acwhite211 Feb 10, 2026
b05a03b
fix ajax mock during jest teardown
acwhite211 Feb 10, 2026
1fb27e7
Lint code with ESLint and Prettier
acwhite211 Feb 10, 2026
385574b
avoid collectionPreferences fetch mock error
acwhite211 Feb 10, 2026
90457e7
add new synonymize tests
acwhite211 Feb 10, 2026
3df948c
Revert "add new synonymize tests"
acwhite211 Feb 10, 2026
7bcd952
Lint code with ESLint and Prettier
acwhite211 Feb 10, 2026
b2fea78
simplify tree extras
acwhite211 Feb 10, 2026
ba1e064
Update update_locality.py
acwhite211 Feb 10, 2026
c296d32
Update Actions.tsx
acwhite211 Feb 10, 2026
c93c3b1
Update stackTrace.ts
acwhite211 Feb 10, 2026
77cfeff
Update treeBusinessRules.ts
acwhite211 Feb 10, 2026
8b327c0
Update CollectionDefinitions.tsx
acwhite211 Feb 10, 2026
1cb34ed
Update .gitignore
acwhite211 Feb 10, 2026
d16fa7b
Update businessRules.test.ts
acwhite211 Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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):

@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)
Expand All @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 upload_locality_set
Expand Down
Original file line number Diff line number Diff line change
@@ -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=[])
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions specifyweb/backend/merge/tests/test_abort_merge_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions specifyweb/backend/merge/tests/test_merging_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 3 additions & 3 deletions specifyweb/backend/merge/tests/test_record_merge_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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):
Expand Down
73 changes: 46 additions & 27 deletions specifyweb/backend/trees/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,25 +207,55 @@ def close_interval(model, node_number, size):
highestchildnodenumber=F('highestchildnodenumber')-size,
)

def adding_node(node,collection=None, user=None):
def _get_collection_preferences(collection=None, user=None):
import specifyweb.backend.context.app_resource as app_resource
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')
if collection_prefs_json is not None:
collection_prefs_dict = json.loads(collection_prefs_json)
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 {}

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:
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 collection_prefs_dict if isinstance(collection_prefs_dict, dict) else {}

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', {})

treeManagement_pref = collection_prefs_dict.get('treeManagement', {})
synonymized = tree_management_pref.get('synonymized', {}) \
if isinstance(tree_management_pref, dict) else {}

synonymized = treeManagement_pref.get('synonymized', {}) \
if isinstance(treeManagement_pref, dict) else {}
if not isinstance(synonymized, dict):
return 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
pref_key = f'sp7.allow_adding_child_to_synonymized_parent.{node.specify_model.name}'
return synonymized.get(pref_key, False) is True

if add_synonym_enabled 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:
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",
Expand Down Expand Up @@ -410,19 +440,8 @@ 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')
if collection_prefs_json is not None:
collection_prefs_dict = json.loads(collection_prefs_json)

treeManagement_pref = collection_prefs_dict.get('treeManagement', {})

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):
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",
Expand Down Expand Up @@ -840,4 +859,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)
30 changes: 25 additions & 5 deletions specifyweb/frontend/js_src/lib/tests/ajax/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ export function overrideAjax(
}

const basePathParts = process.cwd().split('/');
const basePath = basePathParts
.slice(0, basePathParts.indexOf('js_src') + 1)
.join('/');
const jsSourcePathIndex = basePathParts.indexOf('js_src');
const basePath =
jsSourcePathIndex === -1
? process.cwd()
: basePathParts.slice(0, jsSourcePathIndex + 1).join('/');

/**
* When process.env.NODE_ENV === 'test', this intercepts the AJAX requests
Expand All @@ -98,7 +100,19 @@ export async function ajaxMock<RESPONSE_TYPE>(
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] ??
Expand All @@ -117,7 +131,13 @@ export async function ajaxMock<RESPONSE_TYPE>(
* 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
Expand Down
Loading