From b3320ec466e1671ec618fd0fd58630f9745434e1 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 20 Apr 2026 16:13:30 -0500 Subject: [PATCH 01/31] Fix cascaded Discipline delete blockers for Division deletes --- specifyweb/backend/delete_blockers/views.py | 7 +++-- specifyweb/specify/api/crud.py | 31 +++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/specifyweb/backend/delete_blockers/views.py b/specifyweb/backend/delete_blockers/views.py index 034a5339b2c..c73cf223d29 100644 --- a/specifyweb/backend/delete_blockers/views.py +++ b/specifyweb/backend/delete_blockers/views.py @@ -6,6 +6,7 @@ from specifyweb.specify.api.crud import ( get_discipline_delete_guard_blockers, get_object_or_404, + prepare_delete_cascade_disciplines, prepare_discipline_for_delete, ) from specifyweb.specify.api.serializers import toJson @@ -32,8 +33,10 @@ def delete_blockers(request, model, id): result = _collect_delete_blockers(obj, using) transaction.set_rollback(True, using=using) else: - # Standard delete blockers behavior - result = _collect_delete_blockers(obj, using) + with transaction.atomic(using=using): + prepare_delete_cascade_disciplines(obj, using) + result = _collect_delete_blockers(obj, using) + transaction.set_rollback(True, using=using) return http.HttpResponse(toJson(result), content_type='application/json') diff --git a/specifyweb/specify/api/crud.py b/specifyweb/specify/api/crud.py index 7f3b7d788c6..930244cfc55 100644 --- a/specifyweb/specify/api/crud.py +++ b/specifyweb/specify/api/crud.py @@ -4,7 +4,8 @@ import logging from typing import Any, Dict from collections.abc import Callable -from django.db import transaction +from django.db import transaction, router +from django.db.models.deletion import Collector from django.core.exceptions import FieldError, FieldDoesNotExist from django.db.models import Model, F, Q, Subquery from django.http import (HttpResponseServerError, Http404) @@ -331,6 +332,30 @@ def prepare_discipline_for_delete(obj) -> None: tree_def_model.objects.filter(discipline_id=obj.id).update(discipline_id=None) delete_discipline_owned_setup_data(obj) +def get_delete_cascade_disciplines(obj, using) -> list[models.Discipline]: + """ + Return any disciplines that would be deleted by cascading from obj + """ + collector = Collector(using=using) + collector.delete_blockers = [] + collector.collect([obj]) + + disciplines = { + candidate.id: candidate + for collected_objs in collector.data.values() + for candidate in collected_objs + if is_discipline(candidate) + } + return [disciplines[discipline_id] for discipline_id in sorted(disciplines)] + +def prepare_delete_cascade_disciplines(obj, using) -> None: + """ + Apply discipline pre-delete cleanup to any discipline reached through + a cascading delete from obj. + """ + for discipline in get_delete_cascade_disciplines(obj, using): + prepare_discipline_for_delete(discipline) + @transaction.atomic def put_resource(collection, agent, name: str, id, version, data: dict[str, Any]): return update_obj(collection, agent, name, id, version, data) @@ -450,6 +475,7 @@ def delete_resource(collection, agent, name, id, version) -> None: locking 'version'. """ obj = get_object_or_404(name, id=int(id)) + using = router.db_for_write(obj.__class__, instance=obj) if is_discipline(obj): guard_blockers = get_discipline_delete_guard_blockers(obj) if guard_blockers: @@ -458,7 +484,8 @@ def delete_resource(collection, agent, name, id, version) -> None: ) clean_predelete = prepare_discipline_for_delete else: - clean_predelete = None + def clean_predelete(delete_obj): + prepare_delete_cascade_disciplines(delete_obj, using) return delete_obj( obj, From f26b61f8a874579c1982811893588663c58714cf Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 20 Apr 2026 16:52:17 -0500 Subject: [PATCH 02/31] mypy fix --- specifyweb/backend/delete_blockers/views.py | 2 +- specifyweb/specify/api/crud.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/specifyweb/backend/delete_blockers/views.py b/specifyweb/backend/delete_blockers/views.py index c73cf223d29..da70c0203f2 100644 --- a/specifyweb/backend/delete_blockers/views.py +++ b/specifyweb/backend/delete_blockers/views.py @@ -42,7 +42,7 @@ def delete_blockers(request, model, id): def _collect_delete_blockers(obj, using) -> list[dict]: collector = Collector(using=using) - collector.delete_blockers = [] + setattr(collector, "delete_blockers", []) collector.collect([obj]) return flatten([ [ diff --git a/specifyweb/specify/api/crud.py b/specifyweb/specify/api/crud.py index 930244cfc55..919d9ffd96b 100644 --- a/specifyweb/specify/api/crud.py +++ b/specifyweb/specify/api/crud.py @@ -337,7 +337,7 @@ def get_delete_cascade_disciplines(obj, using) -> list[models.Discipline]: Return any disciplines that would be deleted by cascading from obj """ collector = Collector(using=using) - collector.delete_blockers = [] + setattr(collector, "delete_blockers", []) collector.collect([obj]) disciplines = { @@ -350,8 +350,7 @@ def get_delete_cascade_disciplines(obj, using) -> list[models.Discipline]: def prepare_delete_cascade_disciplines(obj, using) -> None: """ - Apply discipline pre-delete cleanup to any discipline reached through - a cascading delete from obj. + Apply discipline pre-delete cleanup to any discipline reached through a cascading delete from obj """ for discipline in get_delete_cascade_disciplines(obj, using): prepare_discipline_for_delete(discipline) From 324d90f3d4be8cad5ed46a2d06e0555fa2cd51cc Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 20 Apr 2026 17:03:51 -0500 Subject: [PATCH 03/31] Create unit tests for cascade deletes --- .../specify/tests/test_delete_blockers.py | 127 +++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/specifyweb/specify/tests/test_delete_blockers.py b/specifyweb/specify/tests/test_delete_blockers.py index eabc239939e..d9e10c5b2fc 100644 --- a/specifyweb/specify/tests/test_delete_blockers.py +++ b/specifyweb/specify/tests/test_delete_blockers.py @@ -1,7 +1,9 @@ -from django.test import Client import json +from django.test import Client from specifyweb.backend.trees.tests.test_trees import GeographyTree +from specifyweb.specify import models +from specifyweb.specify.api.crud import delete_resource def _url(obj): return f"/delete_blockers/delete_blockers/{obj._meta.model_name}/{obj.id}/" @@ -31,6 +33,98 @@ def _get_blockers(self, obj): response = self.c.get(_url(obj)) return json.loads(response.content.decode()) + def _create_discipline_with_owned_export_schema( + self, + discipline, + name='Disposable Export Schema', + ): + schema = models.Spexportschema.objects.create( + discipline=discipline, + schemaname=name, + ) + mapping = models.Spexportschemamapping.objects.create( + collectionmemberid=self.collection.id, + mappingname=f'{name} Mapping', + ) + models.Spexportschema_exportmapping.objects.create( + spexportschema=schema, + spexportschemamapping=mapping, + ) + + def _create_discipline_with_owned_trees( + self, + name='Disposable Discipline', + division=None, + ): + placeholder_geo = models.Geographytreedef.objects.create( + name=f'{name} placeholder geo' + ) + placeholder_geo_time = models.Geologictimeperiodtreedef.objects.create( + name=f'{name} placeholder geotime' + ) + + discipline = models.Discipline.objects.create( + name=name, + type='paleobotany', + division=self.division if division is None else division, + datatype=self.datatype, + geographytreedef=placeholder_geo, + geologictimeperiodtreedef=placeholder_geo_time, + ) + + geography_tree = models.Geographytreedef.objects.create( + name=f'{name} geography', + discipline=discipline, + ) + geography_rank = models.Geographytreedefitem.objects.create( + name='Planet', + rankid=0, + treedef=geography_tree, + ) + models.Geography.objects.create( + name='Earth', + rankid=0, + definition=geography_tree, + definitionitem=geography_rank, + ) + + geotime_tree = models.Geologictimeperiodtreedef.objects.create( + name=f'{name} geotime', + discipline=discipline, + ) + geotime_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Root', + rankid=0, + treedef=geotime_tree, + ) + models.Geologictimeperiod.objects.create( + name='Root', + rankid=0, + definition=geotime_tree, + definitionitem=geotime_rank, + ) + + taxon_tree = models.Taxontreedef.objects.create( + name=f'{name} taxon', + discipline=discipline, + ) + taxon_rank = models.Taxontreedefitem.objects.create( + name='Life', + rankid=0, + treedef=taxon_tree, + ) + models.Taxon.objects.create( + name='Life', + rankid=0, + definition=taxon_tree, + definitionitem=taxon_rank, + ) + + discipline.geographytreedef = geography_tree + discipline.geologictimeperiodtreedef = geotime_tree + discipline.taxontreedef = taxon_tree + discipline.save() + return discipline def test_simple_agent_delete_blockers(self): prep_list = [] @@ -63,3 +157,34 @@ def test_children_dont_block_deletion(self): for node in self._node_list: self._assertSame(self._get_blockers(node), []) + + def test_division_delete_blockers_ignore_cascaded_discipline_setup_rows(self): + division = models.Division.objects.create( + name='Disposable Division', + institution=self.institution, + ) + discipline = self._create_discipline_with_owned_trees( + 'Division Discipline', + division=division, + ) + self._create_discipline_with_owned_export_schema(discipline) + + blockers = self._get_blockers(division) + self._assertSame(blockers, []) + + def test_delete_division_removes_owned_setup_from_cascaded_discipline(self): + division = models.Division.objects.create( + name='Deletable Division', + institution=self.institution, + ) + discipline = self._create_discipline_with_owned_trees( + 'Division Delete Discipline', + division=division, + ) + self._create_discipline_with_owned_export_schema(discipline) + + delete_resource( + self.collection, self.agent, 'division', division.id, division.version + ) + self.assertFalse(models.Division.objects.filter(id=division.id).exists()) + self.assertFalse(models.Discipline.objects.filter(id=discipline.id).exists()) From fa14bb5b774eb2f30b83df7370a039c8cc5e3533 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 20 Apr 2026 22:50:38 -0500 Subject: [PATCH 04/31] Implement get_delete_cascade_discipline_guard_blockers --- specifyweb/backend/delete_blockers/views.py | 19 ++++- specifyweb/specify/api/crud.py | 95 ++++++++++++++++++++- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/specifyweb/backend/delete_blockers/views.py b/specifyweb/backend/delete_blockers/views.py index da70c0203f2..b2c3656f23c 100644 --- a/specifyweb/backend/delete_blockers/views.py +++ b/specifyweb/backend/delete_blockers/views.py @@ -5,6 +5,8 @@ from specifyweb.middleware.general import require_http_methods from specifyweb.specify.api.crud import ( get_discipline_delete_guard_blockers, + get_delete_cascade_discipline_guard_blockers, + get_delete_cascade_disciplines, get_object_or_404, prepare_delete_cascade_disciplines, prepare_discipline_for_delete, @@ -33,10 +35,19 @@ def delete_blockers(request, model, id): result = _collect_delete_blockers(obj, using) transaction.set_rollback(True, using=using) else: - with transaction.atomic(using=using): - prepare_delete_cascade_disciplines(obj, using) - result = _collect_delete_blockers(obj, using) - transaction.set_rollback(True, using=using) + cascade_disciplines = get_delete_cascade_disciplines(obj, using) + guard_blockers = get_delete_cascade_discipline_guard_blockers( + obj, using, cascade_disciplines + ) + if guard_blockers: + result = guard_blockers + else: + with transaction.atomic(using=using): + prepare_delete_cascade_disciplines( + obj, using, cascade_disciplines + ) + result = _collect_delete_blockers(obj, using) + transaction.set_rollback(True, using=using) return http.HttpResponse(toJson(result), content_type='application/json') diff --git a/specifyweb/specify/api/crud.py b/specifyweb/specify/api/crud.py index 919d9ffd96b..f3459bc9eaa 100644 --- a/specifyweb/specify/api/crud.py +++ b/specifyweb/specify/api/crud.py @@ -248,9 +248,62 @@ def get_discipline_delete_guard_blockers(discipline) -> list[dict[str, Any]]: ) return blockers +def merge_delete_blockers( + blockers: list[dict[str, Any]], +) -> list[dict[str, Any]]: + merged: dict[tuple[str, str], set[int]] = {} + for blocker in blockers: + key = (blocker["table"], blocker["field"]) + ids = merged.setdefault(key, set()) + ids.update(int(blocker_id) for blocker_id in blocker["ids"]) + + return [ + { + "table": table, + "field": field, + "ids": sorted(ids), + } + for (table, field), ids in sorted(merged.items()) + ] + def _raw_delete_queryset(queryset) -> None: queryset._raw_delete(queryset.db) +def delete_discipline_owned_app_resources(obj) -> None: + if not is_discipline(obj): + return + + resource_dir_ids = models.Spappresourcedir.objects.filter( + discipline_id=obj.id + ).values("id") + app_resource_ids = models.Spappresource.objects.filter( + spappresourcedir_id__in=Subquery(resource_dir_ids) + ).values("id") + viewset_ids = models.Spviewsetobj.objects.filter( + spappresourcedir_id__in=Subquery(resource_dir_ids) + ).values("id") + + _raw_delete_queryset( + models.Spappresourcedata.objects.filter( + Q(spappresource_id__in=Subquery(app_resource_ids)) + | Q(spviewsetobj_id__in=Subquery(viewset_ids)) + ) + ) + _raw_delete_queryset( + models.Spreport.objects.filter(appresource_id__in=Subquery(app_resource_ids)) + ) + _raw_delete_queryset( + models.Spappresource.objects.filter( + spappresourcedir_id__in=Subquery(resource_dir_ids) + ) + ) + _raw_delete_queryset( + models.Spviewsetobj.objects.filter( + spappresourcedir_id__in=Subquery(resource_dir_ids) + ) + ) + _raw_delete_queryset(models.Spappresourcedir.objects.filter(discipline_id=obj.id)) + def delete_discipline_owned_setup_data(obj) -> None: """ Remove discipline-scoped setup/config rows in bulk before the final @@ -316,7 +369,7 @@ def delete_discipline_owned_setup_data(obj) -> None: ) _raw_delete_queryset(UniquenessRule.objects.filter(discipline_id=obj.id)) - _raw_delete_queryset(models.Spappresourcedir.objects.filter(discipline_id=obj.id)) + delete_discipline_owned_app_resources(obj) _raw_delete_queryset(models.Sptasksemaphore.objects.filter(discipline_id=obj.id)) _raw_delete_queryset(models.Autonumschdsp.objects.filter(discipline_id=obj.id)) @@ -348,11 +401,34 @@ def get_delete_cascade_disciplines(obj, using) -> list[models.Discipline]: } return [disciplines[discipline_id] for discipline_id in sorted(disciplines)] -def prepare_delete_cascade_disciplines(obj, using) -> None: +def get_delete_cascade_discipline_guard_blockers( + obj, + using, + disciplines: list[models.Discipline] | None = None, +) -> list[dict[str, Any]]: + if disciplines is None: + disciplines = get_delete_cascade_disciplines(obj, using) + + return merge_delete_blockers( + [ + blocker + for discipline in disciplines + for blocker in get_discipline_delete_guard_blockers(discipline) + ] + ) + +def prepare_delete_cascade_disciplines( + obj, + using, + disciplines: list[models.Discipline] | None = None, +) -> None: """ Apply discipline pre-delete cleanup to any discipline reached through a cascading delete from obj """ - for discipline in get_delete_cascade_disciplines(obj, using): + if disciplines is None: + disciplines = get_delete_cascade_disciplines(obj, using) + + for discipline in disciplines: prepare_discipline_for_delete(discipline) @transaction.atomic @@ -483,8 +559,19 @@ def delete_resource(collection, agent, name, id, version) -> None: ) clean_predelete = prepare_discipline_for_delete else: + cascade_disciplines = get_delete_cascade_disciplines(obj, using) + guard_blockers = get_delete_cascade_discipline_guard_blockers( + obj, using, cascade_disciplines + ) + if guard_blockers: + raise BusinessRuleException( + "A cascaded Discipline cannot be deleted while it has associated users or collections." + ) + def clean_predelete(delete_obj): - prepare_delete_cascade_disciplines(delete_obj, using) + prepare_delete_cascade_disciplines( + delete_obj, using, cascade_disciplines + ) return delete_obj( obj, From 9a47326d9527e1f91f177fae4e9d7268a456c497 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 21 Apr 2026 12:30:16 -0500 Subject: [PATCH 05/31] Add more division delete blocker unit tests --- .../specify/tests/test_delete_blockers.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/specifyweb/specify/tests/test_delete_blockers.py b/specifyweb/specify/tests/test_delete_blockers.py index d9e10c5b2fc..db7438fe805 100644 --- a/specifyweb/specify/tests/test_delete_blockers.py +++ b/specifyweb/specify/tests/test_delete_blockers.py @@ -1,6 +1,7 @@ import json from django.test import Client +from specifyweb.backend.businessrules.exceptions import BusinessRuleException from specifyweb.backend.trees.tests.test_trees import GeographyTree from specifyweb.specify import models from specifyweb.specify.api.crud import delete_resource @@ -51,6 +52,41 @@ def _create_discipline_with_owned_export_schema( spexportschemamapping=mapping, ) + def _create_discipline_with_owned_app_resources( + self, + discipline, + name='Disposable App Resources', + ): + resource_dir = models.Spappresourcedir.objects.create( + discipline=discipline, + ispersonal=False, + ) + app_resource = models.Spappresource.objects.create( + name=f'{name} Resource', + level=0, + spappresourcedir=resource_dir, + specifyuser=self.specifyuser, + ) + models.Spappresourcedata.objects.create( + spappresource=app_resource, + data=b'{}', + ) + models.Spreport.objects.create( + name=f'{name} Report', + appresource=app_resource, + specifyuser=self.specifyuser, + ) + viewset = models.Spviewsetobj.objects.create( + name=f'{name} Viewset', + level=0, + spappresourcedir=resource_dir, + ) + models.Spappresourcedata.objects.create( + spviewsetobj=viewset, + data=b'{}', + ) + return resource_dir + def _create_discipline_with_owned_trees( self, name='Disposable Discipline', @@ -188,3 +224,61 @@ def test_delete_division_removes_owned_setup_from_cascaded_discipline(self): ) self.assertFalse(models.Division.objects.filter(id=division.id).exists()) self.assertFalse(models.Discipline.objects.filter(id=discipline.id).exists()) + + def test_division_delete_blockers_ignore_cascaded_discipline_app_resource_rows(self): + division = models.Division.objects.create( + name='App Resource Division', + institution=self.institution, + ) + discipline = self._create_discipline_with_owned_trees( + 'App Resource Discipline', + division=division, + ) + self._create_discipline_with_owned_app_resources(discipline) + + blockers = self._get_blockers(division) + self._assertSame(blockers, []) + + def test_delete_division_removes_cascaded_discipline_app_resource_rows(self): + division = models.Division.objects.create( + name='App Resource Delete Division', + institution=self.institution, + ) + discipline = self._create_discipline_with_owned_trees( + 'App Resource Delete Discipline', + division=division, + ) + resource_dir = self._create_discipline_with_owned_app_resources(discipline) + + delete_resource( + self.collection, self.agent, 'division', division.id, division.version + ) + self.assertFalse(models.Division.objects.filter(id=division.id).exists()) + self.assertFalse(models.Discipline.objects.filter(id=discipline.id).exists()) + self.assertFalse(models.Spappresourcedir.objects.filter(id=resource_dir.id).exists()) + + def test_division_delete_blockers_include_cascaded_discipline_user_blockers(self): + division = models.Division.objects.create( + name='User Blocker Division', + institution=self.institution, + ) + discipline = self._create_discipline_with_owned_trees( + 'User Blocker Discipline', + division=division, + ) + resource_dir = models.Spappresourcedir.objects.create( + discipline=discipline, + specifyuser=self.specifyuser, + ispersonal=False, + ) + + blockers = self._get_blockers(division) + self._assertSame( + blockers, + [dict(table='Spappresourcedir', field='specifyuser', ids=[resource_dir.id])], + ) + + with self.assertRaises(BusinessRuleException): + delete_resource( + self.collection, self.agent, 'division', division.id, division.version + ) From 84d344a7e9b7515f53c17de017ea15d575fcb3ae Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 22 Apr 2026 11:46:26 -0500 Subject: [PATCH 06/31] Update test_delete_blockers.py --- .../specify/tests/test_delete_blockers.py | 280 +++++------------- 1 file changed, 80 insertions(+), 200 deletions(-) diff --git a/specifyweb/specify/tests/test_delete_blockers.py b/specifyweb/specify/tests/test_delete_blockers.py index db7438fe805..b6e721e528d 100644 --- a/specifyweb/specify/tests/test_delete_blockers.py +++ b/specifyweb/specify/tests/test_delete_blockers.py @@ -1,10 +1,11 @@ -import json from django.test import Client +import json -from specifyweb.backend.businessrules.exceptions import BusinessRuleException -from specifyweb.backend.trees.tests.test_trees import GeographyTree from specifyweb.specify import models -from specifyweb.specify.api.crud import delete_resource +from django.db import router +from django.test import TestCase +from specifyweb.backend.delete_blockers.views import _collect_delete_blockers +from specifyweb.backend.trees.tests.test_trees import GeographyTree def _url(obj): return f"/delete_blockers/delete_blockers/{obj._meta.model_name}/{obj.id}/" @@ -32,135 +33,20 @@ def _assertSame(self, base, other): def _get_blockers(self, obj): response = self.c.get(_url(obj)) - return json.loads(response.content.decode()) - - def _create_discipline_with_owned_export_schema( - self, - discipline, - name='Disposable Export Schema', - ): - schema = models.Spexportschema.objects.create( - discipline=discipline, - schemaname=name, - ) - mapping = models.Spexportschemamapping.objects.create( - collectionmemberid=self.collection.id, - mappingname=f'{name} Mapping', - ) - models.Spexportschema_exportmapping.objects.create( - spexportschema=schema, - spexportschemamapping=mapping, - ) - - def _create_discipline_with_owned_app_resources( - self, - discipline, - name='Disposable App Resources', - ): - resource_dir = models.Spappresourcedir.objects.create( - discipline=discipline, - ispersonal=False, - ) - app_resource = models.Spappresource.objects.create( - name=f'{name} Resource', - level=0, - spappresourcedir=resource_dir, - specifyuser=self.specifyuser, - ) - models.Spappresourcedata.objects.create( - spappresource=app_resource, - data=b'{}', - ) - models.Spreport.objects.create( - name=f'{name} Report', - appresource=app_resource, - specifyuser=self.specifyuser, - ) - viewset = models.Spviewsetobj.objects.create( - name=f'{name} Viewset', - level=0, - spappresourcedir=resource_dir, - ) - models.Spappresourcedata.objects.create( - spviewsetobj=viewset, - data=b'{}', - ) - return resource_dir - - def _create_discipline_with_owned_trees( - self, - name='Disposable Discipline', - division=None, - ): - placeholder_geo = models.Geographytreedef.objects.create( - name=f'{name} placeholder geo' - ) - placeholder_geo_time = models.Geologictimeperiodtreedef.objects.create( - name=f'{name} placeholder geotime' - ) - - discipline = models.Discipline.objects.create( - name=name, - type='paleobotany', - division=self.division if division is None else division, - datatype=self.datatype, - geographytreedef=placeholder_geo, - geologictimeperiodtreedef=placeholder_geo_time, - ) - - geography_tree = models.Geographytreedef.objects.create( - name=f'{name} geography', - discipline=discipline, - ) - geography_rank = models.Geographytreedefitem.objects.create( - name='Planet', - rankid=0, - treedef=geography_tree, - ) - models.Geography.objects.create( - name='Earth', - rankid=0, - definition=geography_tree, - definitionitem=geography_rank, - ) - - geotime_tree = models.Geologictimeperiodtreedef.objects.create( - name=f'{name} geotime', - discipline=discipline, - ) - geotime_rank = models.Geologictimeperiodtreedefitem.objects.create( - name='Root', - rankid=0, - treedef=geotime_tree, - ) - models.Geologictimeperiod.objects.create( - name='Root', - rankid=0, - definition=geotime_tree, - definitionitem=geotime_rank, + self.assertEqual( + response.status_code, + 200, + f"ERROR: {response.content.decode()}", ) + return json.loads(response.content.decode()) - taxon_tree = models.Taxontreedef.objects.create( - name=f'{name} taxon', - discipline=discipline, - ) - taxon_rank = models.Taxontreedefitem.objects.create( - name='Life', - rankid=0, - treedef=taxon_tree, - ) - models.Taxon.objects.create( - name='Life', - rankid=0, - definition=taxon_tree, - definitionitem=taxon_rank, - ) + def _assertContains(self, blockers, expected): + normalized = [ + {**obj, 'ids': sorted(obj['ids'])} + for obj in blockers + ] + self.assertIn({**expected, 'ids': sorted(expected['ids'])}, normalized) - discipline.geographytreedef = geography_tree - discipline.geologictimeperiodtreedef = geotime_tree - discipline.taxontreedef = taxon_tree - discipline.save() - return discipline def test_simple_agent_delete_blockers(self): prep_list = [] @@ -194,91 +80,85 @@ def test_children_dont_block_deletion(self): for node in self._node_list: self._assertSame(self._get_blockers(node), []) - def test_division_delete_blockers_ignore_cascaded_discipline_setup_rows(self): - division = models.Division.objects.create( - name='Disposable Division', - institution=self.institution, + def test_many_to_many_join_blockers_are_normalized(self): + export_schema = models.Spexportschema.objects.create( + discipline=self.discipline ) - discipline = self._create_discipline_with_owned_trees( - 'Division Discipline', - division=division, + export_mapping = models.Spexportschemamapping.objects.create( + collectionmemberid=self.collection.id ) - self._create_discipline_with_owned_export_schema(discipline) + export_schema.mappings.add(export_mapping) - blockers = self._get_blockers(division) - self._assertSame(blockers, []) + delete_blockers = self._get_blockers(export_schema) - def test_delete_division_removes_owned_setup_from_cascaded_discipline(self): - division = models.Division.objects.create( - name='Deletable Division', - institution=self.institution, - ) - discipline = self._create_discipline_with_owned_trees( - 'Division Delete Discipline', - division=division, - ) - self._create_discipline_with_owned_export_schema(discipline) + expected = [ + dict( + table='SpExportSchemaMapping', + field='spExportSchemas', + ids=[export_mapping.id], + ) + ] - delete_resource( - self.collection, self.agent, 'division', division.id, division.version - ) - self.assertFalse(models.Division.objects.filter(id=division.id).exists()) - self.assertFalse(models.Discipline.objects.filter(id=discipline.id).exists()) + self._assertSame(delete_blockers, expected) - def test_division_delete_blockers_ignore_cascaded_discipline_app_resource_rows(self): - division = models.Division.objects.create( - name='App Resource Division', - institution=self.institution, - ) - discipline = self._create_discipline_with_owned_trees( - 'App Resource Discipline', - division=division, - ) - self._create_discipline_with_owned_app_resources(discipline) +class TestDeleteBlockersCascade(TestCase): - blockers = self._get_blockers(division) - self._assertSame(blockers, []) + def _assertContains(self, blockers, expected): + normalized = [ + {**obj, 'ids': sorted(obj['ids'])} + for obj in blockers + ] + self.assertIn({**expected, 'ids': sorted(expected['ids'])}, normalized) - def test_delete_division_removes_cascaded_discipline_app_resource_rows(self): + def test_division_collects_normalized_cascaded_discipline_blockers(self): + institution = models.Institution.objects.create( + name='Test Institution', + isaccessionsglobal=True, + issecurityon=False, + isserverbased=False, + issharinglocalities=True, + issinglegeographytree=True, + ) division = models.Division.objects.create( - name='App Resource Delete Division', - institution=self.institution, + institution=institution, + name='Test Division', ) - discipline = self._create_discipline_with_owned_trees( - 'App Resource Delete Discipline', - division=division, + geologictimeperiodtreedef = models.Geologictimeperiodtreedef.objects.create( + name='Test gtptd' ) - resource_dir = self._create_discipline_with_owned_app_resources(discipline) - - delete_resource( - self.collection, self.agent, 'division', division.id, division.version + geographytreedef = models.Geographytreedef.objects.create( + name='Test gtd' ) - self.assertFalse(models.Division.objects.filter(id=division.id).exists()) - self.assertFalse(models.Discipline.objects.filter(id=discipline.id).exists()) - self.assertFalse(models.Spappresourcedir.objects.filter(id=resource_dir.id).exists()) - - def test_division_delete_blockers_include_cascaded_discipline_user_blockers(self): - division = models.Division.objects.create( - name='User Blocker Division', - institution=self.institution, - ) - discipline = self._create_discipline_with_owned_trees( - 'User Blocker Discipline', + datatype = models.Datatype.objects.create(name='Test datatype') + discipline = models.Discipline.objects.create( + geologictimeperiodtreedef=geologictimeperiodtreedef, + geographytreedef=geographytreedef, division=division, + datatype=datatype, + type='paleobotany', ) - resource_dir = models.Spappresourcedir.objects.create( - discipline=discipline, - specifyuser=self.specifyuser, - ispersonal=False, + export_schema = models.Spexportschema.objects.create( + discipline=discipline ) - - blockers = self._get_blockers(division) - self._assertSame( - blockers, - [dict(table='Spappresourcedir', field='specifyuser', ids=[resource_dir.id])], + export_mapping = models.Spexportschemamapping.objects.create( + collectionmemberid=1 ) + export_schema.mappings.add(export_mapping) - with self.assertRaises(BusinessRuleException): - delete_resource( - self.collection, self.agent, 'division', division.id, division.version + using = router.db_for_write(division.__class__, instance=division) + delete_blockers = _collect_delete_blockers(division, using) + + self._assertContains( + delete_blockers, + dict( + table='SpExportSchemaMapping', + field='spExportSchemas', + ids=[export_mapping.id], + ), + ) + self.assertFalse( + any( + blocker['table'] == 'Spexportschema_exportmapping' + for blocker in delete_blockers ) + ) From 23d2e8d6ee24920260eba86cd58bde87c8a0648c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 22 Apr 2026 11:49:17 -0500 Subject: [PATCH 07/31] Simplify delete blockers --- specifyweb/backend/delete_blockers/views.py | 84 +++++++++++++++------ 1 file changed, 62 insertions(+), 22 deletions(-) diff --git a/specifyweb/backend/delete_blockers/views.py b/specifyweb/backend/delete_blockers/views.py index b2c3656f23c..e562390aa76 100644 --- a/specifyweb/backend/delete_blockers/views.py +++ b/specifyweb/backend/delete_blockers/views.py @@ -1,14 +1,12 @@ from django import http from django.db import router, transaction from django.db.models.deletion import Collector +from django.db.models import ForeignKey from specifyweb.middleware.general import require_http_methods from specifyweb.specify.api.crud import ( get_discipline_delete_guard_blockers, - get_delete_cascade_discipline_guard_blockers, - get_delete_cascade_disciplines, get_object_or_404, - prepare_delete_cascade_disciplines, prepare_discipline_for_delete, ) from specifyweb.specify.api.serializers import toJson @@ -35,35 +33,77 @@ def delete_blockers(request, model, id): result = _collect_delete_blockers(obj, using) transaction.set_rollback(True, using=using) else: - cascade_disciplines = get_delete_cascade_disciplines(obj, using) - guard_blockers = get_delete_cascade_discipline_guard_blockers( - obj, using, cascade_disciplines - ) - if guard_blockers: - result = guard_blockers - else: - with transaction.atomic(using=using): - prepare_delete_cascade_disciplines( - obj, using, cascade_disciplines - ) - result = _collect_delete_blockers(obj, using) - transaction.set_rollback(True, using=using) + # Standard delete blockers behavior + result = _collect_delete_blockers(obj, using) return http.HttpResponse(toJson(result), content_type='application/json') def _collect_delete_blockers(obj, using) -> list[dict]: collector = Collector(using=using) - setattr(collector, "delete_blockers", []) + collector.delete_blockers = [] collector.collect([obj]) return flatten([ [ - { - 'table': sub_objs[0].__class__.__name__, - 'field': field.name, - 'ids': [sub_obj.id for sub_obj in sub_objs] - } + _serialize_delete_blocker(field, sub_objs) ] for field, sub_objs in collector.delete_blockers ]) +def _serialize_delete_blocker(field, sub_objs) -> dict: + normalized = _normalize_many_to_many_blocker(field, sub_objs) + if normalized is not None: + return normalized + + return { + 'table': sub_objs[0].__class__.__name__, + 'field': field.name, + 'ids': [sub_obj.id for sub_obj in sub_objs] + } + +def _normalize_many_to_many_blocker(field, sub_objs) -> dict | None: + through_model = sub_objs[0].__class__ + if hasattr(through_model, 'specify_model'): + return None + + foreign_keys = [ + model_field + for model_field in through_model._meta.fields + if isinstance(model_field, ForeignKey) + ] + if len(foreign_keys) != 2: + return None + + other_field = next( + ( + model_field + for model_field in foreign_keys + if model_field.name != field.name + ), + None, + ) + if other_field is None: + return None + + other_model = other_field.related_model + if not hasattr(other_model, 'specify_model'): + return None + + relationship = next( + ( + relationship + for relationship in other_model.specify_model.relationships + if getattr(relationship, 'through_model', None) == through_model.__name__ + and getattr(relationship, 'through_field', None) == other_field.name + ), + None, + ) + if relationship is None: + return None + + return { + 'table': other_model.specify_model.name, + 'field': relationship.name, + 'ids': [getattr(sub_obj, other_field.attname) for sub_obj in sub_objs], + } + def flatten(l): return [item for sublist in l for item in sublist] From efd0ef2787360e37b4d2b5ae633b910dec3c0405 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 22 Apr 2026 11:50:39 -0500 Subject: [PATCH 08/31] Reformat TestDeleteBlockersCascade --- .../specify/tests/test_delete_blockers.py | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/specifyweb/specify/tests/test_delete_blockers.py b/specifyweb/specify/tests/test_delete_blockers.py index b6e721e528d..67363150d31 100644 --- a/specifyweb/specify/tests/test_delete_blockers.py +++ b/specifyweb/specify/tests/test_delete_blockers.py @@ -162,3 +162,167 @@ def test_division_collects_normalized_cascaded_discipline_blockers(self): for blocker in delete_blockers ) ) +from django.test import Client +import json + +from specifyweb.specify import models +from django.db import router +from django.test import TestCase +from specifyweb.backend.delete_blockers.views import _collect_delete_blockers +from specifyweb.backend.trees.tests.test_trees import GeographyTree + +def _url(obj): + return f"/delete_blockers/delete_blockers/{obj._meta.model_name}/{obj.id}/" + +class TestDeleteBlockers(GeographyTree): + + def setUp(self): + super().setUp() + self._create_prep_type() + c = Client() + c.force_login(self.specifyuser) + self.c = c + + def _assertSame(self, base, other): + key = lambda obj: (obj['table'], obj['field']) + + sort_by_ids = lambda _blockers: [{**obj, 'ids': sorted(obj['ids'])} for obj in _blockers] + base = sorted(base, key=key) + other = sorted(other, key=key) + + base = sort_by_ids(base) + other = sort_by_ids(other) + + self.assertEqual(base, other) + + def _get_blockers(self, obj): + response = self.c.get(_url(obj)) + self.assertEqual( + response.status_code, + 200, + f"ERROR: {response.content.decode()}", + ) + return json.loads(response.content.decode()) + + def _assertContains(self, blockers, expected): + normalized = [ + {**obj, 'ids': sorted(obj['ids'])} + for obj in blockers + ] + self.assertIn({**expected, 'ids': sorted(expected['ids'])}, normalized) + + + def test_simple_agent_delete_blockers(self): + prep_list = [] + for co in self.collectionobjects: + self._update(co, {'cataloger': self.agent, 'createdbyagent': self.agent}) + for _ in range(5): + self._create_prep(co, prep_list, preparedbyagent=self.agent) + + delete_blockers = self._get_blockers(self.agent) + + expected = [ + dict(table='Collectionobject', field='cataloger', ids=[co.id for co in self.collectionobjects]), + dict(table='Collectionobject', field='createdbyagent', ids=[co.id for co in self.collectionobjects]), + dict(table='Preparation', field='preparedbyagent', ids=[prep.id for prep in prep_list]) + ] + + self._assertSame(delete_blockers, expected) + + def test_to_many_dependents_not_in_blockers(self): + prep_list = [] + for co in self.collectionobjects: + for _ in range(5): + self._create_prep(co, prep_list, preparedbyagent=self.agent) + + for co in self.collectionobjects: + delete_blockers = self._get_blockers(co) + self._assertSame(delete_blockers, []) + + def test_children_dont_block_deletion(self): + + for node in self._node_list: + self._assertSame(self._get_blockers(node), []) + + def test_many_to_many_join_blockers_are_normalized(self): + export_schema = models.Spexportschema.objects.create( + discipline=self.discipline + ) + export_mapping = models.Spexportschemamapping.objects.create( + collectionmemberid=self.collection.id + ) + export_schema.mappings.add(export_mapping) + + delete_blockers = self._get_blockers(export_schema) + + expected = [ + dict( + table='SpExportSchemaMapping', + field='spExportSchemas', + ids=[export_mapping.id], + ) + ] + + self._assertSame(delete_blockers, expected) + +class TestDeleteBlockersCascade(TestCase): + + def _assertContains(self, blockers, expected): + normalized = [ + {**obj, 'ids': sorted(obj['ids'])} + for obj in blockers + ] + self.assertIn({**expected, 'ids': sorted(expected['ids'])}, normalized) + + def test_division_collects_normalized_cascaded_discipline_blockers(self): + institution = models.Institution.objects.create( + name='Test Institution', + isaccessionsglobal=True, + issecurityon=False, + isserverbased=False, + issharinglocalities=True, + issinglegeographytree=True, + ) + division = models.Division.objects.create( + institution=institution, + name='Test Division', + ) + geologictimeperiodtreedef = models.Geologictimeperiodtreedef.objects.create( + name='Test gtptd' + ) + geographytreedef = models.Geographytreedef.objects.create( + name='Test gtd' + ) + datatype = models.Datatype.objects.create(name='Test datatype') + discipline = models.Discipline.objects.create( + geologictimeperiodtreedef=geologictimeperiodtreedef, + geographytreedef=geographytreedef, + division=division, + datatype=datatype, + type='paleobotany', + ) + export_schema = models.Spexportschema.objects.create( + discipline=discipline + ) + export_mapping = models.Spexportschemamapping.objects.create( + collectionmemberid=1 + ) + export_schema.mappings.add(export_mapping) + + using = router.db_for_write(division.__class__, instance=division) + delete_blockers = _collect_delete_blockers(division, using) + + self._assertContains( + delete_blockers, + dict( + table='SpExportSchemaMapping', + field='spExportSchemas', + ids=[export_mapping.id], + ), + ) + self.assertFalse( + any( + blocker['table'] == 'Spexportschema_exportmapping' + for blocker in delete_blockers + ) + ) From bdb3ef4afa33d6294d719f97149a446a926efe2e Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 22 Apr 2026 11:51:53 -0500 Subject: [PATCH 09/31] Revert crud.py changes --- specifyweb/specify/api/crud.py | 121 ++------------------------------- 1 file changed, 4 insertions(+), 117 deletions(-) diff --git a/specifyweb/specify/api/crud.py b/specifyweb/specify/api/crud.py index f3459bc9eaa..a20cdc0abe2 100644 --- a/specifyweb/specify/api/crud.py +++ b/specifyweb/specify/api/crud.py @@ -4,8 +4,7 @@ import logging from typing import Any, Dict from collections.abc import Callable -from django.db import transaction, router -from django.db.models.deletion import Collector +from django.db import transaction from django.core.exceptions import FieldError, FieldDoesNotExist from django.db.models import Model, F, Q, Subquery from django.http import (HttpResponseServerError, Http404) @@ -248,62 +247,9 @@ def get_discipline_delete_guard_blockers(discipline) -> list[dict[str, Any]]: ) return blockers -def merge_delete_blockers( - blockers: list[dict[str, Any]], -) -> list[dict[str, Any]]: - merged: dict[tuple[str, str], set[int]] = {} - for blocker in blockers: - key = (blocker["table"], blocker["field"]) - ids = merged.setdefault(key, set()) - ids.update(int(blocker_id) for blocker_id in blocker["ids"]) - - return [ - { - "table": table, - "field": field, - "ids": sorted(ids), - } - for (table, field), ids in sorted(merged.items()) - ] - def _raw_delete_queryset(queryset) -> None: queryset._raw_delete(queryset.db) -def delete_discipline_owned_app_resources(obj) -> None: - if not is_discipline(obj): - return - - resource_dir_ids = models.Spappresourcedir.objects.filter( - discipline_id=obj.id - ).values("id") - app_resource_ids = models.Spappresource.objects.filter( - spappresourcedir_id__in=Subquery(resource_dir_ids) - ).values("id") - viewset_ids = models.Spviewsetobj.objects.filter( - spappresourcedir_id__in=Subquery(resource_dir_ids) - ).values("id") - - _raw_delete_queryset( - models.Spappresourcedata.objects.filter( - Q(spappresource_id__in=Subquery(app_resource_ids)) - | Q(spviewsetobj_id__in=Subquery(viewset_ids)) - ) - ) - _raw_delete_queryset( - models.Spreport.objects.filter(appresource_id__in=Subquery(app_resource_ids)) - ) - _raw_delete_queryset( - models.Spappresource.objects.filter( - spappresourcedir_id__in=Subquery(resource_dir_ids) - ) - ) - _raw_delete_queryset( - models.Spviewsetobj.objects.filter( - spappresourcedir_id__in=Subquery(resource_dir_ids) - ) - ) - _raw_delete_queryset(models.Spappresourcedir.objects.filter(discipline_id=obj.id)) - def delete_discipline_owned_setup_data(obj) -> None: """ Remove discipline-scoped setup/config rows in bulk before the final @@ -369,7 +315,7 @@ def delete_discipline_owned_setup_data(obj) -> None: ) _raw_delete_queryset(UniquenessRule.objects.filter(discipline_id=obj.id)) - delete_discipline_owned_app_resources(obj) + _raw_delete_queryset(models.Spappresourcedir.objects.filter(discipline_id=obj.id)) _raw_delete_queryset(models.Sptasksemaphore.objects.filter(discipline_id=obj.id)) _raw_delete_queryset(models.Autonumschdsp.objects.filter(discipline_id=obj.id)) @@ -385,52 +331,6 @@ def prepare_discipline_for_delete(obj) -> None: tree_def_model.objects.filter(discipline_id=obj.id).update(discipline_id=None) delete_discipline_owned_setup_data(obj) -def get_delete_cascade_disciplines(obj, using) -> list[models.Discipline]: - """ - Return any disciplines that would be deleted by cascading from obj - """ - collector = Collector(using=using) - setattr(collector, "delete_blockers", []) - collector.collect([obj]) - - disciplines = { - candidate.id: candidate - for collected_objs in collector.data.values() - for candidate in collected_objs - if is_discipline(candidate) - } - return [disciplines[discipline_id] for discipline_id in sorted(disciplines)] - -def get_delete_cascade_discipline_guard_blockers( - obj, - using, - disciplines: list[models.Discipline] | None = None, -) -> list[dict[str, Any]]: - if disciplines is None: - disciplines = get_delete_cascade_disciplines(obj, using) - - return merge_delete_blockers( - [ - blocker - for discipline in disciplines - for blocker in get_discipline_delete_guard_blockers(discipline) - ] - ) - -def prepare_delete_cascade_disciplines( - obj, - using, - disciplines: list[models.Discipline] | None = None, -) -> None: - """ - Apply discipline pre-delete cleanup to any discipline reached through a cascading delete from obj - """ - if disciplines is None: - disciplines = get_delete_cascade_disciplines(obj, using) - - for discipline in disciplines: - prepare_discipline_for_delete(discipline) - @transaction.atomic def put_resource(collection, agent, name: str, id, version, data: dict[str, Any]): return update_obj(collection, agent, name, id, version, data) @@ -550,7 +450,6 @@ def delete_resource(collection, agent, name, id, version) -> None: locking 'version'. """ obj = get_object_or_404(name, id=int(id)) - using = router.db_for_write(obj.__class__, instance=obj) if is_discipline(obj): guard_blockers = get_discipline_delete_guard_blockers(obj) if guard_blockers: @@ -559,19 +458,7 @@ def delete_resource(collection, agent, name, id, version) -> None: ) clean_predelete = prepare_discipline_for_delete else: - cascade_disciplines = get_delete_cascade_disciplines(obj, using) - guard_blockers = get_delete_cascade_discipline_guard_blockers( - obj, using, cascade_disciplines - ) - if guard_blockers: - raise BusinessRuleException( - "A cascaded Discipline cannot be deleted while it has associated users or collections." - ) - - def clean_predelete(delete_obj): - prepare_delete_cascade_disciplines( - delete_obj, using, cascade_disciplines - ) + clean_predelete = None return delete_obj( obj, @@ -639,4 +526,4 @@ def apply_filters(logged_in_collection, params, model, control_params=GetCollect except FieldError as e: raise OrderByError(e) - return objs \ No newline at end of file + return objs From c13feb932380698be8f22f2619b0a0fbbeb4bda7 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 22 Apr 2026 11:52:49 -0500 Subject: [PATCH 10/31] Update crud.py From 3b57b8b52e58a23f6ead1d58cf4e6f7fd4a709c9 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 22 Apr 2026 11:58:17 -0500 Subject: [PATCH 11/31] Fix policy action order differences between front-end and back-end --- .../frontend/js_src/lib/components/Permissions/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Permissions/index.ts b/specifyweb/frontend/js_src/lib/components/Permissions/index.ts index d1e7e50bcd4..669d7d11a56 100644 --- a/specifyweb/frontend/js_src/lib/components/Permissions/index.ts +++ b/specifyweb/frontend/js_src/lib/components/Permissions/index.ts @@ -64,7 +64,12 @@ export const getDerivedPermissions = () => derivedPermissions; const sortPolicies = (policy: typeof operationPolicies) => JSON.stringify( Object.fromEntries( - Object.entries(policy).sort(sortFunction(([key]) => key)) + Object.entries(policy) + .sort(sortFunction(([key]) => key)) + .map(([key, actions]) => [ + key, + [...actions].sort(sortFunction((action) => action)), + ]) ) ); From 45fd15111ad686032cfdef8e4b0e296d39101a1a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 22 Apr 2026 17:08:54 +0000 Subject: [PATCH 12/31] Lint code with ESLint and Prettier Triggered by 3b57b8b52e58a23f6ead1d58cf4e6f7fd4a709c9 on branch refs/heads/issue-7998 --- specifyweb/frontend/js_src/lib/components/Permissions/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Permissions/index.ts b/specifyweb/frontend/js_src/lib/components/Permissions/index.ts index 669d7d11a56..353822c069e 100644 --- a/specifyweb/frontend/js_src/lib/components/Permissions/index.ts +++ b/specifyweb/frontend/js_src/lib/components/Permissions/index.ts @@ -68,7 +68,7 @@ const sortPolicies = (policy: typeof operationPolicies) => .sort(sortFunction(([key]) => key)) .map(([key, actions]) => [ key, - [...actions].sort(sortFunction((action) => action)), + Array.from(actions).sort(sortFunction((action) => action)), ]) ) ); From 214f53f0b98d008cac4e3368c3e2f9d65aff17fd Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 23 Apr 2026 02:53:47 -0500 Subject: [PATCH 13/31] fix: don't create duplicate SpLocaleContainerItem records Somewhat related to #7988, in the sense that both had the same "underlying casue" of using very specific filters in a QuerySet get_or_create --- .../migration_utils/update_schema_config.py | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 0693b8ba24d..7b0efa4f9f3 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -457,16 +457,26 @@ def update_table_field_schema_config_with_defaults( language="en" ) - sp_local_container_item, _ = Splocalecontaineritem.objects.get_or_create( - name=field_config.name, - container=sp_local_container, - type=field_config.java_type, - ishidden=field_hidden, - isrequired=field_required, - issystem=table.system, - version=0, - picklistname=picklist_name - ) + container_item_attrs = { + "name": field_config.name, + "container": sp_local_container + } + + fetched_sp_locale_container_item = Splocalecontaineritem.objects.filter(**container_item_attrs).first() + + if fetched_sp_locale_container_item is None: + sp_locale_container_item = Splocalecontaineritem.objects.create(**{ + **container_item_attrs, + "type": field_config.java_type, + "ishidden": field_hidden, + "isrequired": field_required, + "issystem": table.system, + "version": 0, + "picklistname": picklist_name + } + ) + else: + sp_locale_container_item = fetched_sp_locale_container_item itm_str_rows = [] for k, text in { @@ -477,7 +487,7 @@ def update_table_field_schema_config_with_defaults( "text": text, "language": "en", "version": 0, - k: sp_local_container_item, + k: sp_locale_container_item, } itm_str_rows.append(row) From a198a4c9e9ce85a7d02bf557f378f2d4952975af Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 23 Apr 2026 21:14:11 -0500 Subject: [PATCH 14/31] fix: order duplicate containers and items by ID --- specifyweb/specify/migration_utils/update_schema_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 7b0efa4f9f3..d182445e7f1 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -311,7 +311,7 @@ def update_table_schema_config_with_defaults( "schematype": table_config.schema_type } - fetched_sp_locale_container = Splocalecontainer.objects.filter(**container_attrs).first() + fetched_sp_locale_container = Splocalecontainer.objects.filter(**container_attrs).order_by("id").first() if fetched_sp_locale_container is None: sp_local_container = Splocalecontainer.objects.create(**{ @@ -462,7 +462,7 @@ def update_table_field_schema_config_with_defaults( "container": sp_local_container } - fetched_sp_locale_container_item = Splocalecontaineritem.objects.filter(**container_item_attrs).first() + fetched_sp_locale_container_item = Splocalecontaineritem.objects.filter(**container_item_attrs).order_by("id").first() if fetched_sp_locale_container_item is None: sp_locale_container_item = Splocalecontaineritem.objects.create(**{ From 05ef410156eeec7a77a474de0fbff30f7da86dfd Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 27 Apr 2026 10:12:31 -0500 Subject: [PATCH 15/31] Fix form column definition precedence --- .../js_src/lib/components/FormParse/index.ts | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts index c7019c45b6c..0e310ca3fa7 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts @@ -89,6 +89,8 @@ export const formTypes = ['form', 'formTable'] as const; export type FormType = (typeof formTypes)[number]; export type FormMode = 'edit' | 'search' | 'view'; +const defaultColumnDefinitionOs = 'lnx'; + let views: R = {}; export const getViewSetApiUrl = (viewName: string): string => @@ -539,10 +541,25 @@ function getColumnDefinitions(viewDefinition: SimpleXmlNode): string { const getColumnDefinition = ( viewDefinition: SimpleXmlNode, os: string | undefined -): string | undefined => - viewDefinition.children.columnDef?.find((child) => - typeof os === 'string' ? getParsedAttribute(child, 'os') === os : true - )?.text; +): string | undefined => { + const columnDefinitions = viewDefinition.children.columnDef; + if (columnDefinitions === undefined) return undefined; + + if (typeof os === 'string') + return columnDefinitions.find( + (child) => getParsedAttribute(child, 'os') === os + )?.text; + + return ( + columnDefinitions.find( + (child) => getParsedAttribute(child, 'os') === defaultColumnDefinitionOs + )?.text ?? + columnDefinitions.find( + (child) => getParsedAttribute(child, 'os') === undefined + )?.text ?? + columnDefinitions[0]?.text + ); +}; const parseRows = async ( rawRows: RA, From 8615e2b48c381715b5dfdd5c0263cbdb27aa2c62 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 27 Apr 2026 11:23:35 -0500 Subject: [PATCH 16/31] Fix column unit test --- .../FormParse/__tests__/index.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts index 7e40f34979f..f116f3d913d 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts @@ -471,6 +471,30 @@ describe('parseFormDefinition', () => { describe('getColumnDefinitions', () => { requireContext(); + test('prefers linux definition over generic definition', () => + expect( + getColumnDefinitions( + xml( + ` + Generic + Linux + ` + ) + ) + ).toBe('Linux')); + + test('uses generic definition if linux definition is not available', () => + expect( + getColumnDefinitions( + xml( + ` + Mac + Generic + ` + ) + ) + ).toBe('Generic')); + test('fall back to first definition available', () => expect( getColumnDefinitions( @@ -510,7 +534,7 @@ theories(getColumnDefinition, [ ), undefined, ], - out: 'B', + out: 'A', }, ]); From a6a08e0e31d87c36b8043ca2a2a4edda3476b143 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 28 Apr 2026 13:53:48 -0500 Subject: [PATCH 17/31] fix: prevent overwriting Loan and Gift Schema items --- .../commands/run_key_migration_functions.py | 1 - .../migration_utils/sp7_schemaconfig.py | 17 ---- .../migration_utils/update_schema_config.py | 96 ++++--------------- .../0039_agent_fields_for_loan_and_gift.py | 2 - 4 files changed, 20 insertions(+), 96 deletions(-) diff --git a/specifyweb/specify/management/commands/run_key_migration_functions.py b/specifyweb/specify/management/commands/run_key_migration_functions.py index c35a33e2890..8401571b818 100644 --- a/specifyweb/specify/management/commands/run_key_migration_functions.py +++ b/specifyweb/specify/management/commands/run_key_migration_functions.py @@ -87,7 +87,6 @@ def apply_schema_overrides_for_all_disciplines(_apps): usc.update_paleo_desc, # specify 0033 usc.update_accession_date_fields, # specify 0034 usc.update_loan_and_gift_agent_fields, # specify 0039 - usc.update_loan_and_gift_agents, # specify 0039 usc.componets_schema_config_migrations, # specify 0040 usc.create_discipline_type_picklist, # specify 0042 usc.update_discipline_type_splocalecontaineritem, # specify 0042 diff --git a/specifyweb/specify/migration_utils/sp7_schemaconfig.py b/specifyweb/specify/migration_utils/sp7_schemaconfig.py index 7e4fce0c947..38e5ba48a88 100644 --- a/specifyweb/specify/migration_utils/sp7_schemaconfig.py +++ b/specifyweb/specify/migration_utils/sp7_schemaconfig.py @@ -411,23 +411,6 @@ 'Gift': ['agent1', 'agent2', 'agent3', 'agent4', 'agent5'], } -MIGRATION_0038_UPDATE_FIELDS = { - 'Loan': [ - ('agent1','Agent 1','Agent 1'), - ('agent2','Agent 2','Agent 2'), - ('agent3','Agent 3','Agent 3'), - ('agent4','Agent 4','Agent 4'), - ('agent5','Agent 5','Agent 5'), - ], - 'Gift': [ - ('agent1','Agent 1','Agent 1'), - ('agent2','Agent 2','Agent 2'), - ('agent3','Agent 3','Agent 3'), - ('agent4','Agent 4','Agent 4'), - ('agent5','Agent 5','Agent 5'), - ] -} - MIGRATION_0040_TABLES = [ ('Component', None), ] diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index d182445e7f1..1bf9cc9e7c4 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -1,7 +1,7 @@ import re import json -from typing import NamedTuple, Tuple +from typing import NamedTuple, Tuple, TypedDict, NotRequired import logging from collections import defaultdict from functools import lru_cache @@ -47,7 +47,6 @@ MIGRATION_0034_UPDATE_FIELDS, MIGRATION_0035_FIELDS, MIGRATION_0038_FIELDS, - MIGRATION_0038_UPDATE_FIELDS, MIGRATION_0040_TABLES, MIGRATION_0040_FIELDS, MIGRATION_0040_UPDATE_FIELDS, @@ -265,12 +264,17 @@ def bulk_create_splocaleitemstr_idempotent(Splocaleitemstr, rows: list[dict]) -> return total_created +class TableDefaults(TypedDict): + name: NotRequired[str] + desc: NotRequired[str] + items: "NotRequired[dict[str, FieldDefaults]]" + def update_table_schema_config_with_defaults( table_name, discipline_id: int, description: str = None, apps = global_apps, - defaults: dict = None, + defaults: TableDefaults | None = None, pending_itemstr_rows: list[dict] | None = None, ): Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') @@ -293,7 +297,7 @@ def update_table_schema_config_with_defaults( pending_itemstr_rows = [] try: - table_defaults = defaults if defaults is not None else dict() + table_defaults = defaults if defaults is not None else TableDefaults() table_name_str = table_defaults.get('name', camel_to_spaced_title_case(uncapitilize(table.name))) table_desc_str = table_defaults.get('desc', camel_to_spaced_title_case(uncapitilize(table.name))) @@ -379,12 +383,19 @@ def revert_table_schema_config(table_name, apps=global_apps): items.delete() containers.delete() +class FieldDefaults(TypedDict): + name: NotRequired[str] + desc: NotRequired[str] + ishidden: NotRequired[str] + isrequired: NotRequired[str] + picklistname: NotRequired[str] + def update_table_field_schema_config_with_defaults( table_name, discipline_id: int, field_name: str, apps = global_apps, - defaults: dict = None, + defaults: FieldDefaults | None = None, pending_itemstr_rows: list[dict] | None = None, ): table = datamodel.get_table(table_name) @@ -1936,86 +1947,19 @@ def revert_version_required(apps): def update_loan_and_gift_agent_fields(apps): Discipline = apps.get_model('specify', 'Discipline') + field_defaults = { + "ishidden": True + } for discipline in Discipline.objects.all(): for table, fields in MIGRATION_0038_FIELDS.items(): for field_name in fields: - update_table_field_schema_config_with_defaults(table, discipline.id, field_name, apps) + update_table_field_schema_config_with_defaults(table, discipline.id, field_name, apps, defaults=field_defaults) def revert_loan_and_gift_agent_fields(apps): for table, fields in MIGRATION_0038_FIELDS.items(): for field_name in fields: revert_table_field_schema_config(table, field_name, apps) -def update_loan_and_gift_agents(apps): - """ - Update field descriptions and display names using MIGRATION_0038_UPDATE_FIELDS - (tuple: (fieldName, newLabel, newDesc)). - """ - Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') - Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') - Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') - - def upsert_single_str(*, itemdesc_id=None, itemname_id=None, text=""): - if (itemdesc_id is None) == (itemname_id is None): - raise ValueError("Exactly one of itemdesc_id or itemname_id must be provided") - - qs = Splocaleitemstr.objects.filter( - itemdesc_id=itemdesc_id, - itemname_id=itemname_id, - ).order_by("id") - - obj = qs.first() - if obj is None: - return Splocaleitemstr.objects.create( - itemdesc_id=itemdesc_id, - itemname_id=itemname_id, - text=text, - ) - - qs.exclude(id=obj.id).delete() - - if obj.text != text: - obj.text = text - obj.save(update_fields=["text"]) - - return obj - - for table, fields in MIGRATION_0038_UPDATE_FIELDS.items(): - containers = Splocalecontainer.objects.filter(name=table.lower()) - - for container in containers: - for (field_name, new_name, new_desc) in fields: - items = Splocalecontaineritem.objects.filter( - container=container, - name=field_name.lower(), - ) - - for item in items: - # Hide the existing field - if not item.ishidden: - item.ishidden = True - item.save(update_fields=["ishidden"]) - - upsert_single_str(itemdesc_id=item.id, text=new_desc) - upsert_single_str(itemname_id=item.id, text=new_name) - -def revert_loan_and_gift_agents(apps): - """ - Revert the field name/description updates. - """ - Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') - Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') - - for table, fields in MIGRATION_0038_UPDATE_FIELDS.items(): - containers = Splocalecontainer.objects.filter(name=table.lower()) - for container in containers: - for (field_name, _, _) in fields: - items = Splocalecontaineritem.objects.filter( - container=container, - name=field_name.lower() - ) - # If needed, reset ishidden or revert text - # ########################################## # Used in 0040_components.py # ########################################## diff --git a/specifyweb/specify/migrations/0039_agent_fields_for_loan_and_gift.py b/specifyweb/specify/migrations/0039_agent_fields_for_loan_and_gift.py index b6109646b4b..d5f1e0d9435 100644 --- a/specifyweb/specify/migrations/0039_agent_fields_for_loan_and_gift.py +++ b/specifyweb/specify/migrations/0039_agent_fields_for_loan_and_gift.py @@ -8,10 +8,8 @@ def consolidated_0038_forward(apps, schema_editor): usc.update_loan_and_gift_agent_fields(apps) - usc.update_loan_and_gift_agents(apps) def consolidated_0038_backward(apps, schema_editor): - usc.revert_loan_and_gift_agents(apps) usc.revert_loan_and_gift_agent_fields(apps) class Migration(migrations.Migration): From 08caee70db79f5453b85b40511236f8d2995df13 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 28 Apr 2026 14:04:58 -0500 Subject: [PATCH 18/31] fix: stop updating SpLocaleItemStr records in place when managing defaults --- .../specify/migration_utils/update_schema_config.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 1bf9cc9e7c4..faf13e42ab0 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -234,7 +234,6 @@ def bulk_create_splocaleitemstr_idempotent(Splocaleitemstr, rows: list[dict]) -> key = (r["language"], r[fk_field].pk) desired_by_key[key] = r - rows_to_update = [] ids_to_delete: set[int] = set() to_create = [] for key, desired_row in desired_by_key.items(): @@ -244,20 +243,12 @@ def bulk_create_splocaleitemstr_idempotent(Splocaleitemstr, rows: list[dict]) -> to_create.append(Splocaleitemstr(**desired_row)) continue - keeper = existing_for_key[0] - if keeper.text != desired_row["text"]: - keeper.text = desired_row["text"] - rows_to_update.append(keeper) - for duplicate in existing_for_key[1:]: ids_to_delete.add(duplicate.id) if ids_to_delete: Splocaleitemstr.objects.filter(id__in=ids_to_delete).delete() - if rows_to_update: - Splocaleitemstr.objects.bulk_update(rows_to_update, ["text"]) - if to_create: Splocaleitemstr.objects.bulk_create(to_create) total_created += len(to_create) From 0809a1b4e2c14cd584fc88d5d6868ad950e8b352 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 28 Apr 2026 14:44:17 -0500 Subject: [PATCH 19/31] fix: stop creating SchemaConfig records for ID fields Also, define a helper on Table to fetch/filter fields --- .../migration_utils/update_schema_config.py | 6 ++--- .../specify/models_utils/load_datamodel.py | 24 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index faf13e42ab0..7119121a6e7 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -340,7 +340,7 @@ def update_table_schema_config_with_defaults( pending_itemstr_rows.extend(item_str_rows) - for field in table.all_fields: + for field in table._all_fields(exclude_id_field=True): field_defaults = None if table_defaults.get('items'): field_defaults = table_defaults['items'].get(field.name.lower()) @@ -618,14 +618,14 @@ def find_missing_schema_config_fields(discipline_id: int, apps=global_apps): if table_name_lower not in container_names: missing_tables.append(table_name) missing_fields[table_name] = sorted( - field.name for field in table.all_fields if field.name + field.name for field in table._all_fields(exclude_id_field=True) if field.name ) continue existing_fields = existing_fields_by_table.get(table_name_lower, set()) missing_in_table = sorted( # sort for better reproducablity field.name - for field in table.all_fields + for field in table._all_fields(exclude_id_field=True) if field.name and field.name.lower() not in existing_fields ) diff --git a/specifyweb/specify/models_utils/load_datamodel.py b/specifyweb/specify/models_utils/load_datamodel.py index dbe2c8f8f72..67e1bfbe136 100644 --- a/specifyweb/specify/models_utils/load_datamodel.py +++ b/specifyweb/specify/models_utils/load_datamodel.py @@ -149,19 +149,29 @@ def name(self) -> str: raise ValueError("classname is required to compute the name") return self.classname.split(".")[-1] + def _all_fields(self, exclude_fields=False, exclude_relationships=False, exclude_id_field=False, exclude_virtual_fields=True) -> Iterable[Union["Field", "Relationship"]]: + if not exclude_fields: + yield from self.fields or [] # Handle None by using an empty list + if not exclude_relationships: + yield from self.relationships or [] # Handle None by using an empty list + if not exclude_virtual_fields: + yield from self.virtual_fields or [] + if (not exclude_id_field) and self.idField is not None: + yield self.idField + @property def django_name(self) -> str: return self.name.capitalize() @property def all_fields(self) -> list[Union["Field", "Relationship"]]: - def af() -> Iterable[Union["Field","Relationship"]]: - yield from self.fields or [] # Handle None by using an empty list - yield from self.relationships or [] # Handle None by using an empty list - if self.idField is not None: - yield self.idField - - return list(af()) + """ + A list of all non-virtual fields (including the ID field) and + relationships for the table. + If you need more granularity over which fields to return, use + _all_fields or a filter object + """ + return list(self._all_fields()) def is_virtual_field(self, fieldname: str) -> bool: From ed3619c3b07f93d8f3df28f7abe5097adea3abe1 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 08:11:16 -0500 Subject: [PATCH 20/31] fix: respect user changes to cot relationship for create_cotype_splocalecontaineritem Duplicate items and strings would be created everytime the function is run. The cause of this is the same as #7988. --- .../commands/run_key_migration_functions.py | 7 ++- .../migration_utils/update_schema_config.py | 53 ++++++++++++++----- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/specifyweb/specify/management/commands/run_key_migration_functions.py b/specifyweb/specify/management/commands/run_key_migration_functions.py index 8401571b818..92794e5b23d 100644 --- a/specifyweb/specify/management/commands/run_key_migration_functions.py +++ b/specifyweb/specify/management/commands/run_key_migration_functions.py @@ -59,6 +59,8 @@ def apply_schema_overrides_for_all_disciplines(_apps): ) apply_schema_defaults_task.apply(args=[discipline.id]) + # PERF: The vast majority of these can be collapsed to a single call to + # update_table_schema_config_with_defaults funcs = [ # usc.update_all_table_schema_config_with_defaults, usc.create_geo_table_schema_config_with_defaults, # specify 0002 @@ -79,7 +81,10 @@ def apply_schema_overrides_for_all_disciplines(_apps): usc.add_tectonicunit_to_pc_in_schema_config, # specify 0020 usc.fix_hidden_geo_prop, # specify 0021 usc.update_schema_config_field_desc, # specify 0023 - usc.update_hidden_prop, # specify 0023 + # BUG: We can't reliably run this function at startup, as there is no + # easy way to differentiate Schema Config tables/fields that should or + # should not be updated for already existing Disciplines. + # usc.update_hidden_prop, # specify 0023 usc.update_storage_unique_id_fields, # specify 0024 usc.update_co_children_fields, # specify 0027 usc.remove_collectionobject_parentco, # specify 0029 diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 7119121a6e7..14b738e11a7 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -841,6 +841,8 @@ def create_geo_table_schema_config_with_defaults(apps): COT_FIELD_NAME = 'collectionObjectType' COT_TEXT = 'Collection Object Type' +# FEAT: Replace this implementation with +# update_table_field_schema_config_with_defaults def create_cotype_splocalecontaineritem(apps): Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') @@ -849,23 +851,46 @@ def create_cotype_splocalecontaineritem(apps): # Create a Splocalecontaineritem record for each CollectionObject Splocalecontainer # NOTE: Each discipline has its own CollectionObject Splocalecontainer for container in Splocalecontainer.objects.filter(name='collectionobject', schematype=0): - container_item, _ = Splocalecontaineritem.objects.get_or_create( + container_item = Splocalecontaineritem.objects.filter( name=COT_FIELD_NAME, - picklistname=COT_PICKLIST_NAME, - type='ManyToOne', - container=container, - isrequired=True - ) - Splocaleitemstr.objects.get_or_create( + container=container + ).first() + if container_item is None: + resolved_item = Splocalecontaineritem.objects.create( + name=COT_FIELD_NAME, + picklistname=COT_PICKLIST_NAME, + type='ManyToOne', + container=container, + isrequired=True + ) + else: + resolved_item = container_item + + field_label = Splocaleitemstr.objects.filter( language='en', - text=COT_TEXT, - itemname=container_item - ) - Splocaleitemstr.objects.get_or_create( + itemname=resolved_item + ).first() + field_desc = Splocaleitemstr.objects.filter( language='en', - text=COT_TEXT, - itemdesc=container_item - ) + itemdesc=resolved_item + ).first() + strings_to_create = [] + if field_label is None: + new_field_label = Splocaleitemstr( + language='en', + itemname=resolved_item, + text=COT_TEXT + ) + strings_to_create.append(new_field_label) + if field_desc is None: + new_field_desc = Splocaleitemstr( + language='en', + itemdesc=resolved_item, + text=COT_TEXT + ) + strings_to_create.append(new_field_desc) + + Splocaleitemstr.objects.bulk_create(strings_to_create) # ########################################## # Used in 0004_stratigraphy_age.py From aeda486d433309c5d9c87e393de64e3395d4a8ef Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 08:21:20 -0500 Subject: [PATCH 21/31] refactor: use defaults kwarg in get_or_create for readability --- .../migration_utils/update_schema_config.py | 56 +++++++------------ 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 14b738e11a7..81cef389ef9 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -851,46 +851,30 @@ def create_cotype_splocalecontaineritem(apps): # Create a Splocalecontaineritem record for each CollectionObject Splocalecontainer # NOTE: Each discipline has its own CollectionObject Splocalecontainer for container in Splocalecontainer.objects.filter(name='collectionobject', schematype=0): - container_item = Splocalecontaineritem.objects.filter( + container_item = Splocalecontaineritem.objects.get_or_create( name=COT_FIELD_NAME, - container=container - ).first() - if container_item is None: - resolved_item = Splocalecontaineritem.objects.create( - name=COT_FIELD_NAME, - picklistname=COT_PICKLIST_NAME, - type='ManyToOne', - container=container, - isrequired=True - ) - else: - resolved_item = container_item + container=container, + defaults={ + "picklistname": COT_PICKLIST_NAME, + "type": 'ManyToOne', + "isrequired": True + } + ) - field_label = Splocaleitemstr.objects.filter( + Splocaleitemstr.objects.get_or_create( language='en', - itemname=resolved_item - ).first() - field_desc = Splocaleitemstr.objects.filter( + itemname=container_item, + defaults={ + "text": COT_TEXT + } + ) + Splocaleitemstr.objects.get_or_create( language='en', - itemdesc=resolved_item - ).first() - strings_to_create = [] - if field_label is None: - new_field_label = Splocaleitemstr( - language='en', - itemname=resolved_item, - text=COT_TEXT - ) - strings_to_create.append(new_field_label) - if field_desc is None: - new_field_desc = Splocaleitemstr( - language='en', - itemdesc=resolved_item, - text=COT_TEXT - ) - strings_to_create.append(new_field_desc) - - Splocaleitemstr.objects.bulk_create(strings_to_create) + itemdesc=container_item, + defaults={ + "text": COT_TEXT + } + ) # ########################################## # Used in 0004_stratigraphy_age.py From 344a139bbd5f2649aaad2433bdf6a8de140309cd Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 08:26:35 -0500 Subject: [PATCH 22/31] fix: stop running update_cog_type_fields with fix_schema_config suite --- .../management/commands/run_key_migration_functions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specifyweb/specify/management/commands/run_key_migration_functions.py b/specifyweb/specify/management/commands/run_key_migration_functions.py index 92794e5b23d..69d2276ce3c 100644 --- a/specifyweb/specify/management/commands/run_key_migration_functions.py +++ b/specifyweb/specify/management/commands/run_key_migration_functions.py @@ -67,7 +67,10 @@ def apply_schema_overrides_for_all_disciplines(_apps): usc.create_cotype_splocalecontaineritem, # specify 0003 usc.create_strat_table_schema_config_with_defaults, # specify 0004 - getting skip warnings usc.create_agetype_picklist, # specify 0004 - usc.update_cog_type_fields, # specify 0007 + # BUG: This should really only be run in the context of the migration, + # and not on startup. See the below BUG comment above + # usc.update_hidden_prop + # usc.update_cog_type_fields, # specify 0007 usc.create_cogtype_picklist, # specify 0007 usc.update_cogtype_splocalecontaineritem, # specify 0007 usc.update_systemcogtypes_picklist, # specify 0007 From 5bcc251cdd43f889c09125edaf776090a063bab0 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 09:17:13 -0500 Subject: [PATCH 23/31] fix: remove one-way data changes from fix_schema_config pipeline There's no easy way to differentiate schema config items for which these should be applied. I suppose the BEST method might be to be exact: only updating Containers/Items/Strings that exactly match the previous values expected with the migrations they aim to fix/resolve --- .../commands/run_key_migration_functions.py | 37 +++++++++++-------- .../migration_utils/update_schema_config.py | 16 ++++---- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/specifyweb/specify/management/commands/run_key_migration_functions.py b/specifyweb/specify/management/commands/run_key_migration_functions.py index 69d2276ce3c..3c12ab336ed 100644 --- a/specifyweb/specify/management/commands/run_key_migration_functions.py +++ b/specifyweb/specify/management/commands/run_key_migration_functions.py @@ -68,36 +68,41 @@ def apply_schema_overrides_for_all_disciplines(_apps): usc.create_strat_table_schema_config_with_defaults, # specify 0004 - getting skip warnings usc.create_agetype_picklist, # specify 0004 # BUG: This should really only be run in the context of the migration, - # and not on startup. See the below BUG comment above - # usc.update_hidden_prop + # and not on startup. See the below BUG comment above usc.update_hidden_prop # usc.update_cog_type_fields, # specify 0007 usc.create_cogtype_picklist, # specify 0007 - usc.update_cogtype_splocalecontaineritem, # specify 0007 - usc.update_systemcogtypes_picklist, # specify 0007 - usc.update_cogtype_type_splocalecontaineritem, # specify 0007 + # BUG: These also shouldn't be run with this suite. These are one way + # data migrations in the contect of migrations meant to resolve + # eariler migrations. + # The functions can be destructive as we can't really discern whether + # or not these functions should be applied + # usc.update_cogtype_splocalecontaineritem, # specify 0007 + # usc.update_systemcogtypes_picklist, # specify 0007 + # usc.update_cogtype_type_splocalecontaineritem, # specify 0007 usc.update_relative_age_fields, # specify 0008 usc.add_cojo_to_schema_config, # specify 0012 usc.update_cog_schema_config, # specify 0013 usc.update_age_schema_config, # specify 0015 - usc.schemaconfig_fixes, # specify 0017 - usc.add_cot_catnum_to_schema, # specify 0018 + # usc.schemaconfig_fixes, # specify 0017 + # usc.add_cot_catnum_to_schema, # specify 0018 usc.add_tectonicunit_to_pc_in_schema_config, # specify 0020 - usc.fix_hidden_geo_prop, # specify 0021 - usc.update_schema_config_field_desc, # specify 0023 + # usc.fix_hidden_geo_prop, # specify 0021 + # usc.update_schema_config_field_desc, # specify 0023 # BUG: We can't reliably run this function at startup, as there is no # easy way to differentiate Schema Config tables/fields that should or # should not be updated for already existing Disciplines. # usc.update_hidden_prop, # specify 0023 usc.update_storage_unique_id_fields, # specify 0024 - usc.update_co_children_fields, # specify 0027 - usc.remove_collectionobject_parentco, # specify 0029 - usc.add_quantities_gift, # specify 0032 - usc.update_paleo_desc, # specify 0033 - usc.update_accession_date_fields, # specify 0034 + # usc.update_co_children_fields, # specify 0027 + # usc.remove_collectionobject_parentco, # specify 0029 + # usc.add_quantities_gift, # specify 0032 + # usc.update_paleo_desc, # specify 0033 + # usc.update_accession_date_fields, # specify 0034 usc.update_loan_and_gift_agent_fields, # specify 0039 - usc.componets_schema_config_migrations, # specify 0040 + usc.remove_componentparent_item, # specify 0040 + usc.create_table_schema_config_with_defaults, # specify 0040 usc.create_discipline_type_picklist, # specify 0042 - usc.update_discipline_type_splocalecontaineritem, # specify 0042 + # usc.update_discipline_type_splocalecontaineritem, # specify 0042 apply_schema_overrides_for_all_disciplines, usc.deduplicate_schema_config_orm, ] diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 81cef389ef9..714e4e7769d 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -1132,7 +1132,10 @@ def revert_update_cog_schema_config(apps): def update_age_schema_config(apps): # Revert before adding to avoid duplicates - revert_update_age_schema_config(apps) + # BUG: This will delete people's potentially modified Schema Config items + # If we want to avoid duplicates, we should check the creation code and + # prevent duplicates being created there + # revert_update_age_schema_config(apps) Discipline = apps.get_model('specify', 'Discipline') for discipline in Discipline.objects.all(): @@ -1964,6 +1967,9 @@ def revert_loan_and_gift_agent_fields(apps): # Used in 0040_components.py # ########################################## +def remove_componentparent_item(apps): + revert_table_field_schema_config("CollectionObject", "componentParent", apps) + def remove_0029_schema_config_fields(apps, schema_editor=None): Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') @@ -2082,14 +2088,6 @@ def reverse_hide_component_fields(apps, schema_editor=None): name=field_name.lower() ) items.update(ishidden=True) - -def componets_schema_config_migrations(apps, schema_editor=None): - remove_0029_schema_config_fields(apps, schema_editor) - create_table_schema_config_with_defaults(apps, schema_editor) - update_schema_config_field_desc(apps, schema_editor) - update_hidden_prop(apps, schema_editor) - create_cotype_splocalecontaineritem(apps) - hide_component_fields(apps, schema_editor) # ########################################## # Used in 0042_discipline_type_picklist.py From 9864d171a6bf1dc50190c36fb42e040552fc4f4f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 09:25:15 -0500 Subject: [PATCH 24/31] fix: correctly unpack get_or_create tuple --- specifyweb/specify/migration_utils/update_schema_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 714e4e7769d..56a0b8012b1 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -851,7 +851,7 @@ def create_cotype_splocalecontaineritem(apps): # Create a Splocalecontaineritem record for each CollectionObject Splocalecontainer # NOTE: Each discipline has its own CollectionObject Splocalecontainer for container in Splocalecontainer.objects.filter(name='collectionobject', schematype=0): - container_item = Splocalecontaineritem.objects.get_or_create( + container_item, _created = Splocalecontaineritem.objects.get_or_create( name=COT_FIELD_NAME, container=container, defaults={ From 822d07e487e8ec0f2b90950686b7d9941e776cb8 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 10:44:07 -0500 Subject: [PATCH 25/31] chore: correct types in TypedDict --- specifyweb/specify/migration_utils/update_schema_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 56a0b8012b1..c66439789e7 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -377,8 +377,8 @@ def revert_table_schema_config(table_name, apps=global_apps): class FieldDefaults(TypedDict): name: NotRequired[str] desc: NotRequired[str] - ishidden: NotRequired[str] - isrequired: NotRequired[str] + ishidden: NotRequired[bool] + isrequired: NotRequired[bool] picklistname: NotRequired[str] def update_table_field_schema_config_with_defaults( From e4bc67296e70541e6ff4d632ed1667045834e556 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 11:47:49 -0500 Subject: [PATCH 26/31] fix: assume duplicates may exist for CO -> COT in Schema Config --- .../migration_utils/update_schema_config.py | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index c66439789e7..b91927b90a6 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -263,7 +263,7 @@ class TableDefaults(TypedDict): def update_table_schema_config_with_defaults( table_name, discipline_id: int, - description: str = None, + description: str | None = None, apps = global_apps, defaults: TableDefaults | None = None, pending_itemstr_rows: list[dict] | None = None, @@ -851,30 +851,40 @@ def create_cotype_splocalecontaineritem(apps): # Create a Splocalecontaineritem record for each CollectionObject Splocalecontainer # NOTE: Each discipline has its own CollectionObject Splocalecontainer for container in Splocalecontainer.objects.filter(name='collectionobject', schematype=0): - container_item, _created = Splocalecontaineritem.objects.get_or_create( - name=COT_FIELD_NAME, - container=container, - defaults={ - "picklistname": COT_PICKLIST_NAME, - "type": 'ManyToOne', - "isrequired": True - } - ) + container_item_attrs = { + "name": COT_FIELD_NAME, + "container": container + } + container_item = Splocalecontaineritem.objects.filter(**container_item_attrs).order_by("id").first() + if container_item is None: + resolved_item = Splocalecontaineritem.objects.create( + **container_item_attrs, + picklistname=COT_PICKLIST_NAME, + type="ManyToOne", + isrequired=True + ) + else: + resolved_item = container_item - Splocaleitemstr.objects.get_or_create( - language='en', - itemname=container_item, - defaults={ - "text": COT_TEXT - } - ) - Splocaleitemstr.objects.get_or_create( - language='en', - itemdesc=container_item, - defaults={ - "text": COT_TEXT - } - ) + field_label_attrs = { + "language": "en", + "itemname":resolved_item + } + + field_label = Splocaleitemstr.objects.filter(**field_label_attrs).order_by("id").first() + + if field_label is None: + Splocaleitemstr.objects.create(**field_label_attrs, text=COT_TEXT) + + field_desc_attrs = { + "language": "en", + "itemdesc":resolved_item + } + + field_desc = Splocaleitemstr.objects.filter(**field_desc_attrs).order_by("id").first() + + if field_desc is None: + Splocaleitemstr.objects.create(**field_desc_attrs, text=COT_TEXT) # ########################################## # Used in 0004_stratigraphy_age.py From 4a1246fa103e303a2c1c64cec12da13fc90dc3dd Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 12:19:35 -0500 Subject: [PATCH 27/31] fix: only create default TectonicUnit ranks if root doesn't exist --- .../specify/migration_utils/tectonic_ranks.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/specifyweb/specify/migration_utils/tectonic_ranks.py b/specifyweb/specify/migration_utils/tectonic_ranks.py index c2afe88c6fa..66ff6eb6124 100644 --- a/specifyweb/specify/migration_utils/tectonic_ranks.py +++ b/specifyweb/specify/migration_utils/tectonic_ranks.py @@ -15,14 +15,28 @@ def create_default_tectonic_ranks(apps): if not tectonic_tree_def: tectonic_tree_def, _ = TectonicTreeDef.objects.get_or_create(name="Tectonic Unit", discipline=discipline) - root, _ = TectonicUnitTreeDefItem.objects.get_or_create( - name="Root", - title="Root", + root, root_created = TectonicUnitTreeDefItem.objects.get_or_create( rankid=0, parent=None, treedef=tectonic_tree_def, - isenforced=True + defaults={ + "name": "Root", + "title": "Root", + "isenforced": True + } ) + # The root rank already exists in some capacity in the Discipline + # We can assume the user has made modifications to the tree at this + # point, so shouldn't go further with checking/creating lower ranks + if not root_created: + # BUG?: handle setting the tectonicunittreedef on the Discipline + # here? We can probably practically assume it's already set if the + # root node exists. + continue + + # At this point, these get_or_create calls should always be the + # equivalent of create (as we know the root node didn't exist). + # But keeping the get_or_create here just because superstructure, _ = TectonicUnitTreeDefItem.objects.get_or_create( name="Superstructure", title="Superstructure", From 1b10f431808347ef25a590919fb9db00896e0727 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 12:36:52 -0500 Subject: [PATCH 28/31] fix: change filters to prevent duplicates when matching TectonicUnit info --- specifyweb/specify/migration_utils/tectonic_ranks.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specifyweb/specify/migration_utils/tectonic_ranks.py b/specifyweb/specify/migration_utils/tectonic_ranks.py index 66ff6eb6124..2769c98754f 100644 --- a/specifyweb/specify/migration_utils/tectonic_ranks.py +++ b/specifyweb/specify/migration_utils/tectonic_ranks.py @@ -105,23 +105,25 @@ def create_root_tectonic_node(apps): for discipline in Discipline.objects.all(): - tectonic_tree_def = TectonicUnitTreeDef.objects.filter(name="Tectonic Unit", discipline=discipline).first() + tectonic_tree_def = TectonicUnitTreeDef.objects.filter(discipline=discipline).first() if not tectonic_tree_def: tectonic_tree_def, is_created = TectonicUnitTreeDef.objects.get_or_create( name="Tectonic Unit", discipline=discipline ) - tectonic_tree_def_item = TectonicUnitTreeDefItem.objects.filter(treedef=tectonic_tree_def, name="Root").first() + tectonic_tree_def_item = TectonicUnitTreeDefItem.objects.filter(treedef=tectonic_tree_def, parent=None).first() if not tectonic_tree_def_item: tectonic_tree_def_item, is_created = TectonicUnitTreeDefItem.objects.get_or_create( name="Root", title="Root", treedef=tectonic_tree_def, + rankid=0, + parent=None, isenforced=True ) - root = TectonicUnit.objects.filter(name="Root", definition=tectonic_tree_def).first() + root = TectonicUnit.objects.filter(definition=tectonic_tree_def, definitionitem=tectonic_tree_def_item, parent=None).first() if not root: root, is_created = TectonicUnit.objects.get_or_create( name="Root", @@ -137,7 +139,7 @@ def create_root_tectonic_node(apps): if is_created: logger.info(f"Created root tectonic unit for discipline {discipline.name}") - TectonicUnitTreeDefItem.objects.filter(rankid=0, isenforced__isnull=True).update(isenforced=True) + TectonicUnitTreeDefItem.objects.filter(parent=None,rankid=0, isenforced__isnull=True).update(isenforced=True) def revert_create_root_tectonic_node(apps, schema_editor=None): TectonicUnit = apps.get_model('specify', 'TectonicUnit') From 0571dc9195678fa8cb939135ae1ae9bfc1469399 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 12:53:02 -0500 Subject: [PATCH 29/31] fix: prevent duplicate PickListItems being created for SystemCOGTypes --- .../specify/migration_utils/default_cots.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/specifyweb/specify/migration_utils/default_cots.py b/specifyweb/specify/migration_utils/default_cots.py index 000b7572053..1259f18b55e 100644 --- a/specifyweb/specify/migration_utils/default_cots.py +++ b/specifyweb/specify/migration_utils/default_cots.py @@ -68,7 +68,7 @@ def create_cogtype_type_picklist(apps, using='default'): Picklistitem = apps.get_model('specify', 'Picklistitem') for collection in Collection.objects.using(using).all(): - cog_type_picklist, _ = Picklist.objects.using(using).get_or_create( + cog_type_picklist, picklist_created = Picklist.objects.using(using).get_or_create( name='SystemCOGTypes', # Default Collection Object Group Types type=0, collection=collection, @@ -77,12 +77,13 @@ def create_cogtype_type_picklist(apps, using='default'): "readonly": False, } ) - for cog_type in DEFAULT_COG_TYPES: - Picklistitem.objects.using(using).get_or_create( - title=cog_type, - value=cog_type, - picklist=cog_type_picklist - ) + if picklist_created: + for cog_type in DEFAULT_COG_TYPES: + Picklistitem.objects.using(using).get_or_create( + title=cog_type, + value=cog_type, + picklist=cog_type_picklist + ) COTYPE_PICKLIST_NAME = 'CollectionObjectType' FIELD_NAME = 'collectionObjectType' From 52eaa9da5f5f96f4b900997ee3be89d7e1d78009 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 29 Apr 2026 14:22:50 -0500 Subject: [PATCH 30/31] fix: tighten filters for Tectonic key migration functions --- specifyweb/specify/migration_utils/tectonic_ranks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/migration_utils/tectonic_ranks.py b/specifyweb/specify/migration_utils/tectonic_ranks.py index 2769c98754f..d2bc97482de 100644 --- a/specifyweb/specify/migration_utils/tectonic_ranks.py +++ b/specifyweb/specify/migration_utils/tectonic_ranks.py @@ -112,7 +112,7 @@ def create_root_tectonic_node(apps): discipline=discipline ) - tectonic_tree_def_item = TectonicUnitTreeDefItem.objects.filter(treedef=tectonic_tree_def, parent=None).first() + tectonic_tree_def_item = TectonicUnitTreeDefItem.objects.filter(treedef=tectonic_tree_def, rankid=0, parent=None).first() if not tectonic_tree_def_item: tectonic_tree_def_item, is_created = TectonicUnitTreeDefItem.objects.get_or_create( name="Root", @@ -123,7 +123,7 @@ def create_root_tectonic_node(apps): isenforced=True ) - root = TectonicUnit.objects.filter(definition=tectonic_tree_def, definitionitem=tectonic_tree_def_item, parent=None).first() + root = TectonicUnit.objects.filter(definition=tectonic_tree_def, definitionitem=tectonic_tree_def_item, rankid=0, parent=None).first() if not root: root, is_created = TectonicUnit.objects.get_or_create( name="Root", From f4690f74a4c628cac6b821998fdf10e97f1042be Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 1 May 2026 18:14:37 +0000 Subject: [PATCH 31/31] Lint code with ESLint and Prettier Triggered by c10446efc197d5262a30e0194b9af49960fc04ae on branch refs/heads/v7_12_0_5 --- .../components/DataModel/businessRuleDefs.ts | 14 ++++--- .../ExpressSearchConfigDialog.tsx | 4 +- .../ResultsOrderingTab.tsx | 28 +++++++++---- .../ExpressSearchConfigEditor.test.tsx | 41 ++++++++++--------- .../__tests__/RelatedTablesTab.test.tsx | 8 +++- .../__tests__/ResultsOrderingTab.test.tsx | 4 +- .../lib/components/FormCells/FormTable.tsx | 4 +- .../components/Header/ExpressSearchHooks.tsx | 15 ++++--- .../components/Header/ExpressSearchTask.tsx | 20 ++++----- .../Notifications/NotificationRenderers.tsx | 4 +- .../lib/components/WbPlanView/navigator.ts | 2 +- .../js_src/lib/localization/common.ts | 9 ++-- .../js_src/lib/localization/utils/config.ts | 2 +- .../js_src/lib/utils/schemaVisibility.ts | 2 +- 14 files changed, 87 insertions(+), 70 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 5b0fe4fe7d0..d8921c1df49 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -201,9 +201,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = { return undefined; }, catalogNumber: async (resource): Promise => { - const preferences = await import( - '../Preferences/collectionPreferences' - ).then(({ collectionPreferences }) => collectionPreferences); + const preferences = + await import('../Preferences/collectionPreferences').then( + ({ collectionPreferences }) => collectionPreferences + ); const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get( 'uniqueCatalogNumberAccrossComponentAndCO', @@ -429,9 +430,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = { return undefined; }, catalogNumber: async (resource): Promise => { - const preferences = await import( - '../Preferences/collectionPreferences' - ).then(({ collectionPreferences }) => collectionPreferences); + const preferences = + await import('../Preferences/collectionPreferences').then( + ({ collectionPreferences }) => collectionPreferences + ); const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get( 'uniqueCatalogNumberAccrossComponentAndCO', diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigDialog.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigDialog.tsx index 6c5b8a531b5..81cc07e0bd3 100644 --- a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ExpressSearchConfigDialog.tsx @@ -14,7 +14,7 @@ type ExpressSearchConfigDialogProps = { readonly isOpen: boolean; readonly onClose: () => void; readonly onSave?: () => void; -} +}; export function ExpressSearchConfigDialog({ isOpen, @@ -76,7 +76,7 @@ export function ExpressSearchConfigDialog({ isOpen={isOpen} onClose={onClose} > - diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ResultsOrderingTab.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ResultsOrderingTab.tsx index 4478fd66f5a..437e55abc1b 100644 --- a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ResultsOrderingTab.tsx +++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/ResultsOrderingTab.tsx @@ -11,12 +11,17 @@ import { genericTables } from '../DataModel/tables'; function tableLabel(tableName: string): string { return ( - (genericTables[tableName as keyof typeof genericTables]?.label as string | undefined) ?? - camelToHuman(tableName) + (genericTables[tableName as keyof typeof genericTables]?.label as + | string + | undefined) ?? camelToHuman(tableName) ); } -export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onChangeConfig }: any) { +export function ResultsOrderingTab({ + config, + relatedQueriesDefinitions = [], + onChangeConfig, +}: any) { const baseTables = config.tables .filter((t: any) => t.searchFields.some((sf: any) => sf.inUse !== false)) .map((t: any) => ({ @@ -29,8 +34,12 @@ export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onC const activeQueries = config.relatedQueries .filter((rq: any) => rq.isActive) .map((rq: any) => { - const def = relatedQueriesDefinitions.find((def: any) => def.id === rq.id); - const title = def?.name ? getExpressSearchQueryTitle(def.name) : undefined; + const def = relatedQueriesDefinitions.find( + (def: any) => def.id === rq.id + ); + const title = def?.name + ? getExpressSearchQueryTitle(def.name) + : undefined; if (!def || !title || title === String(def.name)) { return undefined; @@ -86,7 +95,9 @@ export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onC return (
-

{expressSearchConfigText.configureResultsOrdering()}

+

+ {expressSearchConfigText.configureResultsOrdering()} +

{expressSearchConfigText.reorderResultsOrderingDescription()}

@@ -99,7 +110,10 @@ export function ResultsOrderingTab({ config, relatedQueriesDefinitions = [], onC > {item.label}
- moveItem(index, 'up')}> + moveItem(index, 'up')} + > {icons.chevronUp} { @@ -65,42 +65,43 @@ describe('ExpressSearchConfigEditor', () => { }); expect(onChangeJSON).toHaveBeenCalled(); - const latestConfig = onChangeJSON.mock.calls[onChangeJSON.mock.calls.length - 1][0]; + const latestConfig = + onChangeJSON.mock.calls[onChangeJSON.mock.calls.length - 1][0]; expect(latestConfig.tables[0].tableName).toBe('Agent'); expect(latestConfig.tables[0].searchFields[0].fieldName).toBe('firstName'); }); test('renders loading state initially', async () => { const { getByText } = mount( - ); expect(getByText('Loading...')).toBeInTheDocument(); - + // Wait for it to finish loading to avoid act warnings await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); }); }); test('renders tabs after data load', async () => { const { findByRole } = mount( - ); - + expect(await findByRole('tablist')).toBeInTheDocument(); }); test('switches tabs correctly', async () => { const { findByText, getByRole, user } = mount( - ); @@ -112,7 +113,7 @@ describe('ExpressSearchConfigEditor', () => { await act(async () => { await user.click(relatedTab); }); - + expect(await findByText('Related Tables Tab')).toBeInTheDocument(); // Click Results Ordering diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/RelatedTablesTab.test.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/RelatedTablesTab.test.tsx index bacba609689..e096a045a54 100644 --- a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/RelatedTablesTab.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/RelatedTablesTab.test.tsx @@ -43,7 +43,9 @@ describe('RelatedTablesTab', () => { expect(onChangeConfig).toHaveBeenCalledTimes(1); const newConfig = onChangeConfig.mock.calls[0][0]; - expect(newConfig.relatedQueries.find((rq: any) => rq.id === '2').isActive).toBe(true); + expect( + newConfig.relatedQueries.find((rq: any) => rq.id === '2').isActive + ).toBe(true); const activeRow = rows[0]; const activeCheckbox = activeRow.querySelector('input[type="checkbox"]'); @@ -54,6 +56,8 @@ describe('RelatedTablesTab', () => { expect(onChangeConfig).toHaveBeenCalledTimes(2); const secondConfig = onChangeConfig.mock.calls[1][0]; - expect(secondConfig.relatedQueries.find((rq: any) => rq.id === '1').isActive).toBe(false); + expect( + secondConfig.relatedQueries.find((rq: any) => rq.id === '1').isActive + ).toBe(false); }); }); diff --git a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ResultsOrderingTab.test.tsx b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ResultsOrderingTab.test.tsx index 5997fc65f7c..4b15f595931 100644 --- a/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ResultsOrderingTab.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/ExpressSearchConfig/__tests__/ResultsOrderingTab.test.tsx @@ -39,9 +39,7 @@ describe('ResultsOrderingTab', () => { displayFields: [], }, ], - relatedQueries: [ - { id: '8', isActive: true, displayOrder: 1 }, - ], + relatedQueries: [{ id: '8', isActive: true, displayOrder: 1 }], }; const onChangeConfig = jest.fn(); diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index 0eaae790141..baf13a48768 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -214,8 +214,8 @@ export function FormTable({ resource.cid, Boolean( resource.specifyTable.name === 'Preparation' && - collectionPreparationPref && - resource.isNew() + collectionPreparationPref && + resource.isNew() ), ]) ) diff --git a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx index a59d68078c7..62177d62253 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchHooks.tsx @@ -52,14 +52,13 @@ export function usePrimarySearch( } async function fetchRelatedSearches(): Promise> { - return contextUnlockedPromise.then( - async (entrypoint) => - entrypoint === 'main' - ? ajax>('/context/available_related_searches.json', { - headers: { Accept: 'application/json' }, - cache: 'no-store', - }).then(({ data }) => data) - : foreverFetch>() + return contextUnlockedPromise.then(async (entrypoint) => + entrypoint === 'main' + ? ajax>('/context/available_related_searches.json', { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }).then(({ data }) => data) + : foreverFetch>() ); } diff --git a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx index 60686c9bccf..b392a409b6d 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx @@ -125,11 +125,7 @@ function ExpressSearchInstructions({ {headerText.documentation()} )} - +
    @@ -147,10 +143,8 @@ export function ExpressSearchView(): JSX.Element { const [pendingQuery] = value; const [isConfigOpen, setIsConfigOpen] = React.useState(false); const [configRefreshTrigger, setConfigRefreshTrigger] = React.useState(0); - const [showInstructions = true, setShowExpressSearchInstructions] = useCachedState( - 'expressSearch', - 'showSearchTips' - ); + const [showInstructions = true, setShowExpressSearchInstructions] = + useCachedState('expressSearch', 'showSearchTips'); const canEditExpressSearchConfig = hasToolPermission('resources', 'read') && hasToolPermission('resources', 'create') && @@ -176,11 +170,15 @@ export function ExpressSearchView(): JSX.Element { setShowExpressSearchInstructions((value) => !value)} + onClick={(): void => + setShowExpressSearchInstructions((value) => !value) + } /> {showInstructions && ( - setShowExpressSearchInstructions(false)} /> + setShowExpressSearchInstructions(false)} + /> )}
    setQuery(pendingQuery)}>
    diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 26f4696c407..790b3373284 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -395,9 +395,7 @@ export const notificationRenderers: IR< ); }, 'collection-creation-starting'() { - return ( -

    {setupToolText.collectionCreationStarted()}

    - ); + return

    {setupToolText.collectionCreationStarted()}

    ; }, default(notification) { console.error('Unknown notification type', { notification }); diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index fca97e9c6be..fbc359e53b4 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -713,4 +713,4 @@ export function getMappingLineData({ : filtered.filter( ({ customSelectSubtype }) => customSelectSubtype !== 'tree' ); -} \ No newline at end of file +} diff --git a/specifyweb/frontend/js_src/lib/localization/common.ts b/specifyweb/frontend/js_src/lib/localization/common.ts index 6ad133cdc01..d75f8bb7f83 100644 --- a/specifyweb/frontend/js_src/lib/localization/common.ts +++ b/specifyweb/frontend/js_src/lib/localization/common.ts @@ -104,13 +104,16 @@ export const commonText = createDictionary({ 'hr-hr': 'Savjeti za pretraživanje', }, expressSearchInstructions: { - 'en-us': 'Separate multiple search terms with spaces, use % anywhere, * at the beginning or end, and wrap terms in quotes for exact multi-word matches.', + 'en-us': + 'Separate multiple search terms with spaces, use % anywhere, * at the beginning or end, and wrap terms in quotes for exact multi-word matches.', }, expressSearchDateFormats: { - 'en-us': 'Dates can be searched using either the YYYY-MM-DD or MM/DD/YYYY format.', + 'en-us': + 'Dates can be searched using either the YYYY-MM-DD or MM/DD/YYYY format.', }, expressSearchPhraseExample: { - 'en-us': 'To search a term with spaces, wrap the phrase in quotes, for example "Clinton Lake".', + 'en-us': + 'To search a term with spaces, wrap the phrase in quotes, for example "Clinton Lake".', }, apply: { 'en-us': 'Apply', diff --git a/specifyweb/frontend/js_src/lib/localization/utils/config.ts b/specifyweb/frontend/js_src/lib/localization/utils/config.ts index e358f61952f..b70b574e01a 100644 --- a/specifyweb/frontend/js_src/lib/localization/utils/config.ts +++ b/specifyweb/frontend/js_src/lib/localization/utils/config.ts @@ -24,7 +24,7 @@ export const languageCodeMapper = { 'de-ch': 'de_CH', 'pt-br': 'pt_BR', 'hr-hr': 'hr', - 'nb': 'nb_NO' + nb: 'nb_NO', } as const; export const languages = Object.keys(languageCodeMapper); diff --git a/specifyweb/frontend/js_src/lib/utils/schemaVisibility.ts b/specifyweb/frontend/js_src/lib/utils/schemaVisibility.ts index fe41c0b752e..9c483b4168b 100644 --- a/specifyweb/frontend/js_src/lib/utils/schemaVisibility.ts +++ b/specifyweb/frontend/js_src/lib/utils/schemaVisibility.ts @@ -5,4 +5,4 @@ export function isSchemaFieldVisible( defaultFieldName?: string ): boolean { return showHiddenFields || !isHidden || fieldName === defaultFieldName; -} \ No newline at end of file +}