From 5e928768d98cc2eb447479da095025fd175770da Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:16:08 -0500 Subject: [PATCH 01/26] feat(schema-config): add atomic schema import endpoint --- .../tests/test_schema_localization_import.py | 75 ++++++++++ specifyweb/backend/context/urls.py | 1 + specifyweb/backend/context/views.py | 129 ++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 specifyweb/backend/context/tests/test_schema_localization_import.py diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py new file mode 100644 index 00000000000..e3a451444c8 --- /dev/null +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -0,0 +1,75 @@ +import json + +from django.test import Client + +from specifyweb.specify import models +from specifyweb.specify.tests.test_api import ApiTests + + +class SchemaLocalizationImportTests(ApiTests): + def setUp(self): + super().setUp() + self.container = models.Splocalecontainer.objects.create( + discipline=self.discipline, name='Accession', schematype=0 + ) + self.item = models.Splocalecontaineritem.objects.create( + container=self.container, name='accessionnumber' + ) + self.client = Client() + self.client.force_login(self.specifyuser) + self.client.cookies['collection'] = str(self.collection.id) + + def test_imports_schema_values_and_skips_unknown_entries(self): + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'language': 'en', + 'schema': { + 'accession': { + 'format': 'Imported Format', + 'name': 'Imported Accession', + 'items': { + 'accessionnumber': { + 'isHidden': True, + 'name': 'Imported Number', + }, + 'removedfield': {'isHidden': True}, + }, + }, + 'removedtable': {'isHidden': True}, + }, + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + self.container.refresh_from_db() + self.item.refresh_from_db() + self.assertEqual(self.container.format, 'Imported Format') + self.assertTrue(self.item.ishidden) + self.assertEqual( + models.Splocaleitemstr.objects.get( + containername=self.container, language='en' + ).text, + 'Imported Accession', + ) + self.assertEqual( + models.Splocaleitemstr.objects.get( + itemname=self.item, language='en' + ).text, + 'Imported Number', + ) + + def test_invalid_values_do_not_write(self): + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'schema': {'accession': {'isHidden': 'yes'}}, + 'language': 'en', + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.container.refresh_from_db() + self.assertIsNone(self.container.format) diff --git a/specifyweb/backend/context/urls.py b/specifyweb/backend/context/urls.py index 4b90082ac0a..1d7cbfcba90 100644 --- a/specifyweb/backend/context/urls.py +++ b/specifyweb/backend/context/urls.py @@ -30,6 +30,7 @@ re_path(r'^viewsets.json$', views.viewsets), re_path(r'^datamodel.json$', views.datamodel), re_path(r'^schema_localization.json$', views.schema_localization), + re_path(r'^schema_localization_import.json$', views.schema_localization_import), re_path(r'^app.resource$', views.app_resource), re_path(r'^available_related_searches.json$', views.available_related_searches), re_path(r'^remoteprefs.properties$', views.remote_prefs), diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 4970bc6dd17..f388fd79f73 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -11,6 +11,7 @@ from django.contrib.auth import authenticate, login as auth_login, \ logout as auth_logout from django.db import connection, transaction +from django.db.models import Q from django.http import Http404, HttpResponse, HttpResponseBadRequest, \ HttpResponseForbidden, JsonResponse from django.urls import URLPattern @@ -29,6 +30,7 @@ CollectionAccessPT from specifyweb.specify.models import Collection, Discipline, Division, Collectionobject, Institution, \ Specifyuser, Spprincipal, Spversion, Collectionobjecttype +from specifyweb.specify import models from specifyweb.specify.models_utils.schema import base_schema from specifyweb.specify.models_utils.serialize_datamodel import datamodel_to_json from specifyweb.specify.api.serializers import uri_for_model @@ -476,6 +478,133 @@ def schema_localization(request): lang = request.GET.get('lang', request.LANGUAGE_CODE) return JsonResponse(get_schema_localization(request.specify_collection, 0, lang)) + +SCHEMA_IMPORT_FIELDS = { + models.Splocalecontainer: {'format', 'aggregator', 'ishidden'}, + models.Splocalecontaineritem: { + 'format', 'ishidden', 'isrequired', 'picklistname', 'weblinkname', + }, +} +SCHEMA_IMPORT_BOOLEAN_FIELDS = {'ishidden', 'isrequired'} + + +def _schema_import_values(data, fields): + if not isinstance(data, dict): + raise ValueError + values = {} + for key, value in data.items(): + key = key.lower() + if key not in fields: + continue + if key in SCHEMA_IMPORT_BOOLEAN_FIELDS: + if type(value) is not bool: + raise ValueError + elif value is not None and not isinstance(value, str): + raise ValueError + values[key] = value + return values + + +def _schema_import_string(operations, parent, parent_field, text, language, country): + if text is None: + return + if not isinstance(text, str): + raise ValueError + string = models.Splocaleitemstr.objects.filter( + **{parent_field: parent, 'language': language, 'country': country} + ).filter(Q(variant='') | Q(variant__isnull=True)).order_by('-id').first() + if string is None: + operations.append(( + 'POST', models.Splocaleitemstr, None, + { + 'text': text, + 'language': language, + 'country': country, + parent_field: uri_for_model(parent, parent.id), + }, + )) + elif string.text != text: + operations.append(('PUT', models.Splocaleitemstr, string, {'text': text})) + + +def _schema_import_operations(collection, schema, language): + if not isinstance(schema, dict): + raise ValueError + language, _, country = language.lower().partition('-') + containers = { + container.name.lower(): container + for container in models.Splocalecontainer.objects.filter( + discipline_id=collection.discipline_id, schematype=0 + ) + } + operations = [] + for table_name, table_data in schema.items(): + container = containers.get(table_name.lower()) + if container is None: + continue + if not isinstance(table_data, dict): + raise ValueError + values = _schema_import_values( + table_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontainer] + ) + if values: + operations.append(('PUT', models.Splocalecontainer, container, values)) + _schema_import_string( + operations, container, 'containername', table_data.get('name'), language, country or None + ) + _schema_import_string( + operations, container, 'containerdesc', table_data.get('desc'), language, country or None + ) + items = table_data.get('items', {}) + if not isinstance(items, dict): + raise ValueError + current_items = {item.name.lower(): item for item in container.items.all()} + for item_name, item_data in items.items(): + item = current_items.get(item_name.lower()) + if item is None: + continue + values = _schema_import_values( + item_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontaineritem] + ) + if values: + operations.append(('PUT', models.Splocalecontaineritem, item, values)) + _schema_import_string( + operations, item, 'itemname', item_data.get('name'), language, country or None + ) + _schema_import_string( + operations, item, 'itemdesc', item_data.get('desc'), language, country or None + ) + return operations + + +@login_maybe_required +@require_http_methods(['POST']) +def schema_localization_import(request): + try: + payload = json.loads(request.body) + schema = payload.get('schema', payload) + operations = _schema_import_operations( + request.specify_collection, + schema, + payload.get('language', request.LANGUAGE_CODE), + ) + except (AttributeError, KeyError, TypeError, ValueError, json.JSONDecodeError): + return HttpResponseBadRequest() + + with transaction.atomic(): + for method, model, resource, data in operations: + if method == 'PUT': + put_resource( + request.specify_collection, request.specify_user_agent, + model.__name__, resource.id, resource.version, data + ) + else: + post_resource( + request.specify_collection, request.specify_user_agent, + model.__name__, data + ) + return JsonResponse({'updated': len(operations)}) + view_parameters_schema = [ { "name" : "name", From e5c70d71dddd95f802b24320ed1ecb5b54cf4222 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:16:17 -0500 Subject: [PATCH 02/26] feat(schema-config): add schema import controls --- .../components/SchemaConfig/Components.tsx | 27 ++++++++ .../lib/components/SchemaConfig/Layout.tsx | 68 +++++++++++++++++++ .../js_src/lib/localization/schema.ts | 15 ++++ 3 files changed, 110 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx index 8adbbc64775..de9ccf2ad99 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx @@ -18,12 +18,17 @@ export function SchemaConfigHeader({ languages, rawLanguage, onSave: handleSave, + onImport: handleImport, + importDisabled = false, }: { readonly languages: SchemaData['languages']; readonly rawLanguage: string; readonly onSave: (() => void) | undefined; + readonly onImport?: (file: File) => void; + readonly importDisabled?: boolean; }): JSX.Element { const [language] = rawLanguage.split('-'); + const importInput = React.useRef(null); return (

@@ -41,6 +46,28 @@ export function SchemaConfigHeader({ > {commonText.export()} + {handleImport !== undefined && ( + <> + { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file !== undefined) handleImport(file); + }} + /> + importInput.current?.click()} + > + {schemaText.importSchema()} + + + )} {commonText.save()} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index 48b9d165354..a15124afaf4 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -3,11 +3,18 @@ import { Outlet, useOutletContext } from 'react-router'; import { useMatch, useParams } from 'react-router-dom'; import { useUnloadProtect } from '../../hooks/navigation'; +import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; +import { ajax } from '../../utils/ajax'; import { Container } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { Link } from '../Atoms/Link'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; +import { Dialog } from '../Molecules/Dialog'; +import { fileToText } from '../Molecules/FilePicker'; import { hasToolPermission } from '../Permissions/helpers'; import { SetSingleResourceContext } from '../Router/Router'; +import { formatUrl } from '../Router/queryString'; import { SchemaConfigHeader } from './Components'; import type { SchemaData } from './schemaData'; import { SchemaConfigSidebar } from './Sidebar'; @@ -46,6 +53,8 @@ function SchemaConfigLayoutContent(): JSX.Element { const tableName = match?.params.tableName ?? ''; const setSingleResource = React.useContext(SetSingleResourceContext); const loading = React.useContext(LoadingContext); + const [importFile, setImportFile] = React.useState(); + const [importError, setImportError] = React.useState(false); React.useEffect(() => { setSingleResource(`/specify/schema-config/${rawLanguage}/`); @@ -67,12 +76,36 @@ function SchemaConfigLayoutContent(): JSX.Element { }) ); }; + const handleImport = (file: File): void => { + setImportError(false); + setImportFile(file); + }; + const confirmImport = (): void => { + if (importFile === undefined) return; + const file = importFile; + setImportFile(undefined); + loading( + fileToText(file) + .then((text) => JSON.parse(text)) + .then((schema) => + ajax('/context/schema_localization_import.json', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { schema, language: rawLanguage }, + }) + ) + .then(() => handleSchemaSaved(rawLanguage, tableName)) + .catch(() => setImportError(true)) + ); + }; return (
@@ -81,6 +114,41 @@ function SchemaConfigLayoutContent(): JSX.Element {
+ {importFile !== undefined && ( + + {commonText.cancel()} + + {schemaText.importSchemaContinue()} + + + } + header={schemaText.importSchema()} + onClose={(): void => setImportFile(undefined)} + > +

{schemaText.importSchemaBackupPrompt()}

+ + {schemaText.downloadSchemaBackup()} + +
+ )} + {importError && ( + {commonText.close()} + } + header={schemaText.importSchema()} + onClose={(): void => setImportError(false)} + > +

{schemaText.importSchemaError()}

+
+ )}
); } diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index cb4a01a9d9b..9424c5b3776 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -9,6 +9,21 @@ import { createDictionary } from './utils'; // Refer to "Guidelines for Programmers" in ./README.md before editing this file export const schemaText = createDictionary({ + importSchema: { + 'en-us': 'Import Schema Configuration', + }, + importSchemaBackupPrompt: { + 'en-us': 'Download a backup of the current schema before importing?', + }, + downloadSchemaBackup: { + 'en-us': 'Download Current Schema', + }, + importSchemaContinue: { + 'en-us': 'Continue Import', + }, + importSchemaError: { + 'en-us': 'The schema could not be imported. The database was not changed.', + }, table: { 'en-us': 'Table', 'ru-ru': 'Стол', From 4c1fe2b14c121b04687f75633a8acdc9e17a7ccf Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:18:50 -0500 Subject: [PATCH 03/26] fix(schema-config): tolerate legacy import values --- specifyweb/backend/context/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index f388fd79f73..e89afdabf79 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -498,9 +498,9 @@ def _schema_import_values(data, fields): continue if key in SCHEMA_IMPORT_BOOLEAN_FIELDS: if type(value) is not bool: - raise ValueError + continue elif value is not None and not isinstance(value, str): - raise ValueError + continue values[key] = value return values @@ -509,7 +509,7 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun if text is None: return if not isinstance(text, str): - raise ValueError + return string = models.Splocaleitemstr.objects.filter( **{parent_field: parent, 'language': language, 'country': country} ).filter(Q(variant='') | Q(variant__isnull=True)).order_by('-id').first() @@ -543,7 +543,7 @@ def _schema_import_operations(collection, schema, language): if container is None: continue if not isinstance(table_data, dict): - raise ValueError + continue values = _schema_import_values( table_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontainer] ) @@ -557,7 +557,7 @@ def _schema_import_operations(collection, schema, language): ) items = table_data.get('items', {}) if not isinstance(items, dict): - raise ValueError + continue current_items = {item.name.lower(): item for item in container.items.all()} for item_name, item_data in items.items(): item = current_items.get(item_name.lower()) From 29a756808e4a8e2d7ff9957dfeb8904d4e01d43f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:38:08 -0500 Subject: [PATCH 04/26] fix(schema-config): build localized import parents --- specifyweb/backend/context/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index e89afdabf79..7bf5f78e27c 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -34,6 +34,7 @@ from specifyweb.specify.models_utils.schema import base_schema from specifyweb.specify.models_utils.serialize_datamodel import datamodel_to_json from specifyweb.specify.api.serializers import uri_for_model +from specifyweb.specify.api.crud import post_resource, put_resource from specifyweb.specify.utils.specify_jar import specify_jar from specifyweb.specify.views import login_maybe_required, openapi from .app_resource import get_app_resource, FORM_RESOURCE_EXCLUDED_LST @@ -520,7 +521,7 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun 'text': text, 'language': language, 'country': country, - parent_field: uri_for_model(parent, parent.id), + parent_field: uri_for_model(parent.__class__, parent.id), }, )) elif string.text != text: From 31afb5180ebe5fa69a1a16468c630c4dcb259220 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:08:37 -0500 Subject: [PATCH 05/26] fix(schema-config): validate import file structure --- .../tests/test_schema_localization_import.py | 11 +++++++++++ specifyweb/backend/context/views.py | 19 ++++++++++++++----- .../components/SchemaConfig/Components.tsx | 2 +- .../lib/components/SchemaConfig/Layout.tsx | 2 ++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index e3a451444c8..bab88c3058d 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -73,3 +73,14 @@ def test_invalid_values_do_not_write(self): self.assertEqual(response.status_code, 400) self.container.refresh_from_db() self.assertIsNone(self.container.format) + + def test_rejects_non_schema_json(self): + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({'not': 'a schema'}), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.container.refresh_from_db() + self.assertIsNone(self.container.format) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 7bf5f78e27c..11677ba8e66 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -487,6 +487,9 @@ def schema_localization(request): }, } SCHEMA_IMPORT_BOOLEAN_FIELDS = {'ishidden', 'isrequired'} +SCHEMA_IMPORT_TABLE_KEYS = { + 'items', 'name', 'desc', *SCHEMA_IMPORT_FIELDS[models.Splocalecontainer] +} def _schema_import_values(data, fields): @@ -499,9 +502,9 @@ def _schema_import_values(data, fields): continue if key in SCHEMA_IMPORT_BOOLEAN_FIELDS: if type(value) is not bool: - continue + raise ValueError elif value is not None and not isinstance(value, str): - continue + raise ValueError values[key] = value return values @@ -510,7 +513,7 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun if text is None: return if not isinstance(text, str): - return + raise ValueError string = models.Splocaleitemstr.objects.filter( **{parent_field: parent, 'language': language, 'country': country} ).filter(Q(variant='') | Q(variant__isnull=True)).order_by('-id').first() @@ -529,7 +532,13 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun def _schema_import_operations(collection, schema, language): - if not isinstance(schema, dict): + if not isinstance(schema, dict) or not schema: + raise ValueError + if not any( + isinstance(data, dict) + and {key.lower() for key in data}.intersection(SCHEMA_IMPORT_TABLE_KEYS) + for data in schema.values() + ): raise ValueError language, _, country = language.lower().partition('-') containers = { @@ -558,7 +567,7 @@ def _schema_import_operations(collection, schema, language): ) items = table_data.get('items', {}) if not isinstance(items, dict): - continue + raise ValueError current_items = {item.name.lower(): item for item in container.items.all()} for item_name, item_data in items.items(): item = current_items.get(item_name.lower()) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx index de9ccf2ad99..eb305e0ec73 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx @@ -64,7 +64,7 @@ export function SchemaConfigHeader({ disabled={importDisabled} onClick={(): void => importInput.current?.click()} > - {schemaText.importSchema()} + {commonText.import()}
)} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index a15124afaf4..7998eb0d752 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -23,6 +23,7 @@ import { SchemaConfigStoreProvider, useSchemaConfig, } from './Store'; +import { dialogIcons } from '../Atoms/Icons'; export function SchemaConfigLayout(): JSX.Element { const schemaData = useOutletContext(); @@ -124,6 +125,7 @@ function SchemaConfigLayoutContent(): JSX.Element { } + icon={dialogIcons.question} header={schemaText.importSchema()} onClose={(): void => setImportFile(undefined)} > From 714f512d227a15df2e1ca4dcfda23431bf8ced5e Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:28:44 -0500 Subject: [PATCH 06/26] refactor(schema-config): reuse localized schema config label --- .../lib/components/SchemaConfig/Layout.tsx | 39 +++++++++++++------ .../js_src/lib/localization/schema.ts | 8 ++-- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index 7998eb0d752..dff9ca59469 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -119,25 +119,34 @@ function SchemaConfigLayoutContent(): JSX.Element { + + {schemaText.downloadSchemaBackup({ + schemaConfig: schemaText.schemaConfig(), + })} + + {commonText.cancel()} {schemaText.importSchemaContinue()} } - icon={dialogIcons.question} - header={schemaText.importSchema()} + icon={dialogIcons.warning} + header={schemaText.importSchema({ + schemaConfig: schemaText.schemaConfig(), + })} onClose={(): void => setImportFile(undefined)} > -

{schemaText.importSchemaBackupPrompt()}

- + {schemaText.importSchemaBackupPrompt({ + schemaConfig: schemaText.schemaConfig(), })} - > - {schemaText.downloadSchemaBackup()} - +

)} {importError && ( @@ -145,10 +154,16 @@ function SchemaConfigLayoutContent(): JSX.Element { buttons={ {commonText.close()} } - header={schemaText.importSchema()} + header={schemaText.importSchema({ + schemaConfig: schemaText.schemaConfig(), + })} onClose={(): void => setImportError(false)} > -

{schemaText.importSchemaError()}

+

+ {schemaText.importSchemaError({ + schemaConfig: schemaText.schemaConfig(), + })} +

)} diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 9424c5b3776..122fd734f60 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -10,19 +10,19 @@ import { createDictionary } from './utils'; export const schemaText = createDictionary({ importSchema: { - 'en-us': 'Import Schema Configuration', + 'en-us': 'Import {schemaConfig:string}', }, importSchemaBackupPrompt: { - 'en-us': 'Download a backup of the current schema before importing?', + 'en-us': 'We strongly recommend downloading a backup of the current {schemaConfig:string} before importing.', }, downloadSchemaBackup: { - 'en-us': 'Download Current Schema', + 'en-us': 'Export {schemaConfig:string}', }, importSchemaContinue: { 'en-us': 'Continue Import', }, importSchemaError: { - 'en-us': 'The schema could not be imported. The database was not changed.', + 'en-us': 'The {schemaConfig:string} could not be imported. The database was not changed.', }, table: { 'en-us': 'Table', From 527de3f476dfad456f3fea2ad2229e6781a28692 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:47:18 -0500 Subject: [PATCH 07/26] fix(schema-config): skip unavailable import references --- .../tests/test_schema_localization_import.py | 8 +++- specifyweb/backend/context/views.py | 44 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index bab88c3058d..84fe8aa0bae 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -26,12 +26,14 @@ def test_imports_schema_values_and_skips_unknown_entries(self): 'language': 'en', 'schema': { 'accession': { - 'format': 'Imported Format', + 'format': 'Accession', 'name': 'Imported Accession', 'items': { 'accessionnumber': { 'isHidden': True, 'name': 'Imported Number', + 'pickListName': 'Unavailable Picklist', + 'webLinkName': 'Unavailable Web Link', }, 'removedfield': {'isHidden': True}, }, @@ -45,8 +47,10 @@ def test_imports_schema_values_and_skips_unknown_entries(self): self.assertEqual(response.status_code, 200) self.container.refresh_from_db() self.item.refresh_from_db() - self.assertEqual(self.container.format, 'Imported Format') + self.assertEqual(self.container.format, 'Accession') self.assertTrue(self.item.ishidden) + self.assertIsNone(self.item.picklistname) + self.assertIsNone(self.item.weblinkname) self.assertEqual( models.Splocaleitemstr.objects.get( containername=self.container, language='en' diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 11677ba8e66..1b9d29e54ee 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -6,6 +6,7 @@ import os import re from typing import List +from xml.etree import ElementTree from django.conf import settings from django.contrib.auth import authenticate, login as auth_login, \ @@ -490,9 +491,25 @@ def schema_localization(request): SCHEMA_IMPORT_TABLE_KEYS = { 'items', 'name', 'desc', *SCHEMA_IMPORT_FIELDS[models.Splocalecontainer] } +SCHEMA_IMPORT_REFERENCE_FIELDS = {'format', 'picklistname', 'weblinkname'} -def _schema_import_values(data, fields): +def _schema_import_resource_names(collection, user, resource, path): + result = get_app_resource(collection, user, resource) + if result is None: + return set() + try: + root = ElementTree.fromstring(result[0]) + except ElementTree.ParseError: + return set() + return { + (element.get('name') or element.text or '').lower() + for element in root.findall(path) + if element.get('name') or element.text + } + + +def _schema_import_values(data, fields, references): if not isinstance(data, dict): raise ValueError values = {} @@ -505,6 +522,9 @@ def _schema_import_values(data, fields): raise ValueError elif value is not None and not isinstance(value, str): raise ValueError + if key in SCHEMA_IMPORT_REFERENCE_FIELDS and value is not None \ + and value.lower() not in references[key]: + continue values[key] = value return values @@ -531,7 +551,7 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun operations.append(('PUT', models.Splocaleitemstr, string, {'text': text})) -def _schema_import_operations(collection, schema, language): +def _schema_import_operations(collection, schema, language, references=None): if not isinstance(schema, dict) or not schema: raise ValueError if not any( @@ -541,6 +561,7 @@ def _schema_import_operations(collection, schema, language): ): raise ValueError language, _, country = language.lower().partition('-') + references = references or {key: set() for key in SCHEMA_IMPORT_REFERENCE_FIELDS} containers = { container.name.lower(): container for container in models.Splocalecontainer.objects.filter( @@ -555,7 +576,7 @@ def _schema_import_operations(collection, schema, language): if not isinstance(table_data, dict): continue values = _schema_import_values( - table_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontainer] + table_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontainer], references ) if values: operations.append(('PUT', models.Splocalecontainer, container, values)) @@ -574,7 +595,7 @@ def _schema_import_operations(collection, schema, language): if item is None: continue values = _schema_import_values( - item_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontaineritem] + item_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontaineritem], references ) if values: operations.append(('PUT', models.Splocalecontaineritem, item, values)) @@ -597,6 +618,21 @@ def schema_localization_import(request): request.specify_collection, schema, payload.get('language', request.LANGUAGE_CODE), + { + 'format': _schema_import_resource_names( + request.specify_collection, request.specify_user, + 'DataObjFormatters', './/format' + ), + 'picklistname': { + name.lower() for name in models.Picklist.objects.filter( + collection=request.specify_collection + ).values_list('name', flat=True) + }, + 'weblinkname': _schema_import_resource_names( + request.specify_collection, request.specify_user, + 'WebLinks', './/weblinkdef/name' + ), + }, ) except (AttributeError, KeyError, TypeError, ValueError, json.JSONDecodeError): return HttpResponseBadRequest() From 1c608a0df4a34a3f69eb437989c33b9a874b114f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:18:18 -0500 Subject: [PATCH 08/26] fix(schema-config): validate import language --- .../tests/test_schema_localization_import.py | 17 +++++++++++++++++ specifyweb/backend/context/views.py | 7 ++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index 84fe8aa0bae..d82264b8c2b 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -88,3 +88,20 @@ def test_rejects_non_schema_json(self): self.assertEqual(response.status_code, 400) self.container.refresh_from_db() self.assertIsNone(self.container.format) + + def test_rejects_invalid_language(self): + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'language': 'en-us-extra', + 'schema': {'accession': {'name': 'Should Not Import'}}, + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse( + models.Splocaleitemstr.objects.filter( + containername=self.container, text='Should Not Import' + ).exists() + ) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 1b9d29e54ee..3c54ded37cb 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -614,10 +614,15 @@ def schema_localization_import(request): try: payload = json.loads(request.body) schema = payload.get('schema', payload) + language = payload.get('language', request.LANGUAGE_CODE) + if not isinstance(language, str) or not re.fullmatch( + r'[^-]{2}(?:-[^-]{2})?', language + ): + raise ValueError operations = _schema_import_operations( request.specify_collection, schema, - payload.get('language', request.LANGUAGE_CODE), + language, { 'format': _schema_import_resource_names( request.specify_collection, request.specify_user, From 5279a97bfaa387b2ef4bd173d5bc9587af2c26c0 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:40:32 +0000 Subject: [PATCH 09/26] Lint code with ESLint and Prettier Triggered by 1c608a0df4a34a3f69eb437989c33b9a874b114f on branch refs/heads/issue-6155-2 --- specifyweb/frontend/js_src/lib/localization/schema.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 122fd734f60..579f10eeb64 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -13,7 +13,8 @@ export const schemaText = createDictionary({ 'en-us': 'Import {schemaConfig:string}', }, importSchemaBackupPrompt: { - 'en-us': 'We strongly recommend downloading a backup of the current {schemaConfig:string} before importing.', + 'en-us': + 'We strongly recommend downloading a backup of the current {schemaConfig:string} before importing.', }, downloadSchemaBackup: { 'en-us': 'Export {schemaConfig:string}', @@ -22,7 +23,8 @@ export const schemaText = createDictionary({ 'en-us': 'Continue Import', }, importSchemaError: { - 'en-us': 'The {schemaConfig:string} could not be imported. The database was not changed.', + 'en-us': + 'The {schemaConfig:string} could not be imported. The database was not changed.', }, table: { 'en-us': 'Table', From 4a65bd4233aca8b351646bdc0c6e5b9ed93541a9 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:59:45 -0500 Subject: [PATCH 10/26] fix(schema-config): contain invalid import errors --- .../frontend/js_src/lib/components/SchemaConfig/Layout.tsx | 4 +++- specifyweb/frontend/js_src/lib/localization/schema.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index dff9ca59469..df28d5de064 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -93,6 +93,7 @@ function SchemaConfigLayoutContent(): JSX.Element { method: 'POST', headers: { Accept: 'application/json' }, body: { schema, language: rawLanguage }, + errorMode: 'silent', }) ) .then(() => handleSchemaSaved(rawLanguage, tableName)) @@ -154,12 +155,13 @@ function SchemaConfigLayoutContent(): JSX.Element { buttons={ {commonText.close()} } + icon={dialogIcons.error} header={schemaText.importSchema({ schemaConfig: schemaText.schemaConfig(), })} onClose={(): void => setImportError(false)} > -

+

{schemaText.importSchemaError({ schemaConfig: schemaText.schemaConfig(), })} diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 122fd734f60..3a75f41c4c1 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -22,7 +22,7 @@ export const schemaText = createDictionary({ 'en-us': 'Continue Import', }, importSchemaError: { - 'en-us': 'The {schemaConfig:string} could not be imported. The database was not changed.', + 'en-us': 'The {schemaConfig:string} export provided is invalid and cannot be imported.', }, table: { 'en-us': 'Table', From 41379d2eb54d3ac502b43c393469937032d0c73c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:10:17 -0500 Subject: [PATCH 11/26] fix(schema-config): separate import warnings --- .../frontend/js_src/lib/components/SchemaConfig/Layout.tsx | 6 ++++++ specifyweb/frontend/js_src/lib/localization/schema.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index df28d5de064..def2e60203f 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -143,11 +143,17 @@ function SchemaConfigLayoutContent(): JSX.Element { })} onClose={(): void => setImportFile(undefined)} > +

+ {schemaText.importSchemaWarning({ + schemaConfig: schemaText.schemaConfig(), + })} +

{schemaText.importSchemaBackupPrompt({ schemaConfig: schemaText.schemaConfig(), })}

+

{schemaText.importSchemaLimitations()}

)} {importError && ( diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index c9d961546f3..a2827c7e107 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -12,10 +12,17 @@ export const schemaText = createDictionary({ importSchema: { 'en-us': 'Import {schemaConfig:string}', }, + importSchemaWarning: { + 'en-us': + 'Importing a {schemaConfig:string} will overwrite the current data model. This action cannot be undone.', + }, importSchemaBackupPrompt: { 'en-us': 'We strongly recommend downloading a backup of the current {schemaConfig:string} before importing.', }, + importSchemaLimitations: { + 'en-us': '.', + } downloadSchemaBackup: { 'en-us': 'Export {schemaConfig:string}', }, From ac5c55633d3231ade5fbdc87bc5f4d25c80e225c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:24:34 -0500 Subject: [PATCH 12/26] fix(schema-config): finalize text --- .../frontend/js_src/lib/components/SchemaConfig/Layout.tsx | 4 ++-- specifyweb/frontend/js_src/lib/localization/schema.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index def2e60203f..f70b220de9d 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -148,12 +148,12 @@ function SchemaConfigLayoutContent(): JSX.Element { schemaConfig: schemaText.schemaConfig(), })}

-

+

{schemaText.importSchemaLimitations()}

+

{schemaText.importSchemaBackupPrompt({ schemaConfig: schemaText.schemaConfig(), })}

-

{schemaText.importSchemaLimitations()}

)} {importError && ( diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index a2827c7e107..54aa992fd23 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -14,15 +14,15 @@ export const schemaText = createDictionary({ }, importSchemaWarning: { 'en-us': - 'Importing a {schemaConfig:string} will overwrite the current data model. This action cannot be undone.', + 'Importing a {schemaConfig:string} will overwrite the current one. This action cannot be undone.', }, importSchemaBackupPrompt: { 'en-us': 'We strongly recommend downloading a backup of the current {schemaConfig:string} before importing.', }, importSchemaLimitations: { - 'en-us': '.', - } + 'en-us': 'This import will not assign pick lists, field formats, or web links unless they already exist.', + }, downloadSchemaBackup: { 'en-us': 'Export {schemaConfig:string}', }, From 56be230e56eca02490b466baba0e73780bb41a9c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:27:15 +0000 Subject: [PATCH 13/26] Lint code with ESLint and Prettier Triggered by ac5c55633d3231ade5fbdc87bc5f4d25c80e225c on branch refs/heads/issue-6155-2 --- specifyweb/frontend/js_src/lib/localization/schema.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 54aa992fd23..1709dddbb8c 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -21,7 +21,8 @@ export const schemaText = createDictionary({ 'We strongly recommend downloading a backup of the current {schemaConfig:string} before importing.', }, importSchemaLimitations: { - 'en-us': 'This import will not assign pick lists, field formats, or web links unless they already exist.', + 'en-us': + 'This import will not assign pick lists, field formats, or web links unless they already exist.', }, downloadSchemaBackup: { 'en-us': 'Export {schemaConfig:string}', @@ -30,7 +31,8 @@ export const schemaText = createDictionary({ 'en-us': 'Continue Import', }, importSchemaError: { - 'en-us': 'The {schemaConfig:string} export provided is invalid and cannot be imported.', + 'en-us': + 'The {schemaConfig:string} export provided is invalid and cannot be imported.', }, table: { 'en-us': 'Table', From 00f4b02de0d5f47bc469f2b9f601c2d0a6fb8b7f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:39:19 -0500 Subject: [PATCH 14/26] fix schema localization import review comments --- .../tests/test_schema_localization_import.py | 28 +++++++++++---- specifyweb/backend/context/views.py | 35 ++++++++++--------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index d82264b8c2b..e777dfc4bd3 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -90,18 +90,32 @@ def test_rejects_non_schema_json(self): self.assertIsNone(self.container.format) def test_rejects_invalid_language(self): + for language in ('en-us-extra', '@@', 'en-$%'): + with self.subTest(language=language): + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'language': language, + 'schema': {'accession': {'name': 'Should Not Import'}}, + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse( + models.Splocaleitemstr.objects.filter( + containername=self.container, text='Should Not Import' + ).exists() + ) + + def test_rejects_non_object_data_for_known_table(self): response = self.client.post( '/context/schema_localization_import.json', data=json.dumps({ - 'language': 'en-us-extra', - 'schema': {'accession': {'name': 'Should Not Import'}}, + 'language': 'en', + 'schema': {'accession': 'invalid'}, }), content_type='application/json', ) self.assertEqual(response.status_code, 400) - self.assertFalse( - models.Splocaleitemstr.objects.filter( - containername=self.container, text='Should Not Import' - ).exists() - ) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 3c54ded37cb..5c055245c33 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -30,12 +30,11 @@ check_permission_targets, skip_collection_access_check, query_pt, \ CollectionAccessPT from specifyweb.specify.models import Collection, Discipline, Division, Collectionobject, Institution, \ - Specifyuser, Spprincipal, Spversion, Collectionobjecttype -from specifyweb.specify import models + Specifyuser, Spprincipal, Spversion, Collectionobjecttype, Picklist, Splocalecontainer, \ + Splocalecontaineritem, Splocaleitemstr from specifyweb.specify.models_utils.schema import base_schema from specifyweb.specify.models_utils.serialize_datamodel import datamodel_to_json from specifyweb.specify.api.serializers import uri_for_model -from specifyweb.specify.api.crud import post_resource, put_resource from specifyweb.specify.utils.specify_jar import specify_jar from specifyweb.specify.views import login_maybe_required, openapi from .app_resource import get_app_resource, FORM_RESOURCE_EXCLUDED_LST @@ -482,14 +481,14 @@ def schema_localization(request): SCHEMA_IMPORT_FIELDS = { - models.Splocalecontainer: {'format', 'aggregator', 'ishidden'}, - models.Splocalecontaineritem: { + Splocalecontainer: {'format', 'aggregator', 'ishidden'}, + Splocalecontaineritem: { 'format', 'ishidden', 'isrequired', 'picklistname', 'weblinkname', }, } SCHEMA_IMPORT_BOOLEAN_FIELDS = {'ishidden', 'isrequired'} SCHEMA_IMPORT_TABLE_KEYS = { - 'items', 'name', 'desc', *SCHEMA_IMPORT_FIELDS[models.Splocalecontainer] + 'items', 'name', 'desc', *SCHEMA_IMPORT_FIELDS[Splocalecontainer] } SCHEMA_IMPORT_REFERENCE_FIELDS = {'format', 'picklistname', 'weblinkname'} @@ -534,12 +533,12 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun return if not isinstance(text, str): raise ValueError - string = models.Splocaleitemstr.objects.filter( + string = Splocaleitemstr.objects.filter( **{parent_field: parent, 'language': language, 'country': country} ).filter(Q(variant='') | Q(variant__isnull=True)).order_by('-id').first() if string is None: operations.append(( - 'POST', models.Splocaleitemstr, None, + 'POST', Splocaleitemstr, None, { 'text': text, 'language': language, @@ -548,7 +547,7 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun }, )) elif string.text != text: - operations.append(('PUT', models.Splocaleitemstr, string, {'text': text})) + operations.append(('PUT', Splocaleitemstr, string, {'text': text})) def _schema_import_operations(collection, schema, language, references=None): @@ -564,7 +563,7 @@ def _schema_import_operations(collection, schema, language, references=None): references = references or {key: set() for key in SCHEMA_IMPORT_REFERENCE_FIELDS} containers = { container.name.lower(): container - for container in models.Splocalecontainer.objects.filter( + for container in Splocalecontainer.objects.filter( discipline_id=collection.discipline_id, schematype=0 ) } @@ -574,12 +573,12 @@ def _schema_import_operations(collection, schema, language, references=None): if container is None: continue if not isinstance(table_data, dict): - continue + raise ValueError values = _schema_import_values( - table_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontainer], references + table_data, SCHEMA_IMPORT_FIELDS[Splocalecontainer], references ) if values: - operations.append(('PUT', models.Splocalecontainer, container, values)) + operations.append(('PUT', Splocalecontainer, container, values)) _schema_import_string( operations, container, 'containername', table_data.get('name'), language, country or None ) @@ -595,10 +594,10 @@ def _schema_import_operations(collection, schema, language, references=None): if item is None: continue values = _schema_import_values( - item_data, SCHEMA_IMPORT_FIELDS[models.Splocalecontaineritem], references + item_data, SCHEMA_IMPORT_FIELDS[Splocalecontaineritem], references ) if values: - operations.append(('PUT', models.Splocalecontaineritem, item, values)) + operations.append(('PUT', Splocalecontaineritem, item, values)) _schema_import_string( operations, item, 'itemname', item_data.get('name'), language, country or None ) @@ -616,7 +615,7 @@ def schema_localization_import(request): schema = payload.get('schema', payload) language = payload.get('language', request.LANGUAGE_CODE) if not isinstance(language, str) or not re.fullmatch( - r'[^-]{2}(?:-[^-]{2})?', language + r'[A-Za-z]{2}(?:-[A-Za-z]{2})?', language ): raise ValueError operations = _schema_import_operations( @@ -629,7 +628,7 @@ def schema_localization_import(request): 'DataObjFormatters', './/format' ), 'picklistname': { - name.lower() for name in models.Picklist.objects.filter( + name.lower() for name in Picklist.objects.filter( collection=request.specify_collection ).values_list('name', flat=True) }, @@ -642,6 +641,8 @@ def schema_localization_import(request): except (AttributeError, KeyError, TypeError, ValueError, json.JSONDecodeError): return HttpResponseBadRequest() + from specifyweb.specify.api.crud import post_resource, put_resource + with transaction.atomic(): for method, model, resource, data in operations: if method == 'PUT': From f69ccaa68dcb4590838d69c47f480af3d7389ebe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:56:48 +0000 Subject: [PATCH 15/26] Lint code with ESLint and Prettier Triggered by abb176a86a21249ae1a6df899cdd6ac81e923c02 on branch refs/heads/issue-6155-2 --- .../lib/components/ChooseCollection/index.tsx | 2 +- .../frontend/js_src/lib/components/Core/Main.tsx | 4 +++- .../js_src/lib/components/Router/Routes.tsx | 13 +++++-------- .../js_src/lib/components/WbToolkit/GeoLocate.tsx | 6 ++---- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx index 53266302ad1..0545e2fb681 100644 --- a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx @@ -172,7 +172,7 @@ function Wrapped({ loading( - ping('/accounts/logout/', {method: 'POST'}).then(() => + ping('/accounts/logout/', { method: 'POST' }).then(() => globalThis.location.assign( formatUrl('/specify/command/logout/', { next: nextUrl }) ) diff --git a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx index 92a8971cc46..21c7bbb9fe4 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx @@ -102,7 +102,9 @@ function MissingAgent(): JSX.Element { }} forceToTop header={userText.noAgent()} - onClose={(): void => globalThis.location.assign('/specify/command/logout/')} + onClose={(): void => + globalThis.location.assign('/specify/command/logout/') + } > {userText.noAgentDescription()} diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index e5c97cfa656..1976bbc90e5 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -466,14 +466,11 @@ export const routes: RA = [ ({ CacheBuster }) => CacheBuster ), }, - { - path: 'logout', - title: userText.logOut(), - element: () => - import('../Logout').then( - ({ Logout }) => Logout - ) - }, + { + path: 'logout', + title: userText.logOut(), + element: () => import('../Logout').then(({ Logout }) => Logout), + }, ], }, { diff --git a/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx index b4dfc1c845b..3feeb2a3ec9 100644 --- a/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx @@ -12,9 +12,7 @@ import { filterArray } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { getLocalityField } from '../Leaflet/helpers'; -import { - getSelectedLocalityColumns, -} from '../Leaflet/wbLocalityDataExtractor'; +import { getSelectedLocalityColumns } from '../Leaflet/wbLocalityDataExtractor'; import type { GeoLocatePayload } from '../Molecules/GeoLocate'; import { GenericGeoLocate } from '../Molecules/GeoLocate'; import type { Dataset } from '../WbPlanView/Wrapped'; @@ -305,4 +303,4 @@ export function buildGeoLocateData( ) ) ); -} \ No newline at end of file +} From e9ccbfc8494e864fca145f3fff1319bbe35d9da1 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 7 Sep 2026 14:59:40 +0200 Subject: [PATCH 16/26] Fix: Normalize locale keys before the lookup --- .../tests/test_schema_localization_import.py | 54 +++++++++++++++++++ specifyweb/backend/context/views.py | 10 +++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index e777dfc4bd3..222f1b4e260 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -64,6 +64,60 @@ def test_imports_schema_values_and_skips_unknown_entries(self): 'Imported Number', ) + def test_import_updates_existing_countryless_string(self): + string = models.Splocaleitemstr.objects.create( + containername=self.container, + language='en', + country='', + text='Existing Accession', + ) + + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'language': 'en', + 'schema': {'accession': {'name': 'Updated Accession'}}, + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + models.Splocaleitemstr.objects.filter( + containername=self.container, language='en' + ).count(), + 1, + ) + string.refresh_from_db() + self.assertEqual(string.text, 'Updated Accession') + + def test_import_updates_existing_case_insensitive_country_string(self): + string = models.Splocaleitemstr.objects.create( + containername=self.container, + language='en', + country='US', + text='Existing US Accession', + ) + + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'language': 'en-US', + 'schema': {'accession': {'name': 'Updated US Accession'}}, + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + models.Splocaleitemstr.objects.filter( + containername=self.container, language='en' + ).count(), + 1, + ) + string.refresh_from_db() + self.assertEqual(string.text, 'Updated US Accession') + def test_invalid_values_do_not_write(self): response = self.client.post( '/context/schema_localization_import.json', diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 5c055245c33..5524239b002 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -533,9 +533,15 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun return if not isinstance(text, str): raise ValueError + country = country.lower() if country else None + country_filter = Q(country__isnull=True) | Q(country='') + if country is not None: + country_filter = Q(country__iexact=country) string = Splocaleitemstr.objects.filter( - **{parent_field: parent, 'language': language, 'country': country} - ).filter(Q(variant='') | Q(variant__isnull=True)).order_by('-id').first() + **{parent_field: parent, 'language': language} + ).filter(country_filter).filter( + Q(variant='') | Q(variant__isnull=True) + ).order_by('-id').first() if string is None: operations.append(( 'POST', Splocaleitemstr, None, From 99f9d0541a1860976fd23d0c2b383447d9a73d24 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:10:06 +0200 Subject: [PATCH 17/26] Update specifyweb/backend/context/views.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- specifyweb/backend/context/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 5524239b002..4bcc30bedc7 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -516,8 +516,12 @@ def _schema_import_values(data, fields, references): key = key.lower() if key not in fields: continue +SCHEMA_IMPORT_NULLABLE_BOOLEAN_FIELDS = {'isrequired'} + if key in SCHEMA_IMPORT_BOOLEAN_FIELDS: - if type(value) is not bool: + if type(value) is not bool and not ( + value is None and key in SCHEMA_IMPORT_NULLABLE_BOOLEAN_FIELDS + ): raise ValueError elif value is not None and not isinstance(value, str): raise ValueError From ef3cee212558fba5950f23c86bc57b8365319cf7 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Thu, 24 Sep 2026 13:13:56 +0200 Subject: [PATCH 18/26] Fix: Serialize localized-string lookup and creation. --- .../tests/test_schema_localization_import.py | 63 ++++++++++- specifyweb/backend/context/views.py | 104 +++++++++--------- 2 files changed, 115 insertions(+), 52 deletions(-) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index 222f1b4e260..ad3ac8dd061 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -1,9 +1,14 @@ import json +import threading +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import patch +from django.db import close_old_connections from django.test import Client +from specifyweb.backend.context.views import _schema_import_string from specifyweb.specify import models -from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.tests.test_api import ApiTests, ApiTransactionTests class SchemaLocalizationImportTests(ApiTests): @@ -173,3 +178,59 @@ def test_rejects_non_object_data_for_known_table(self): ) self.assertEqual(response.status_code, 400) + + +class ConcurrentSchemaLocalizationImportTests(ApiTransactionTests): + def setUp(self): + super().setUp() + self.container = models.Splocalecontainer.objects.create( + discipline=self.discipline, name='Accession', schematype=0 + ) + self.clients = [self._make_client(), self._make_client()] + + def _make_client(self): + client = Client() + client.force_login(self.specifyuser) + client.cookies['collection'] = str(self.collection.id) + return client + + def test_concurrent_imports_do_not_create_duplicate_strings(self): + payload = json.dumps({ + 'language': 'en-US', + 'schema': {'accession': {'name': 'Imported Accession'}}, + }) + barrier = threading.Barrier(2) + + def synchronize_import(*args): + _schema_import_string(*args) + if args[3] is not None: + try: + barrier.wait(timeout=1) + except threading.BrokenBarrierError: + pass + + def import_schema(client): + close_old_connections() + try: + return client.post( + '/context/schema_localization_import.json', + data=payload, + content_type='application/json', + ) + finally: + close_old_connections() + + with patch( + 'specifyweb.backend.context.views._schema_import_string', + side_effect=synchronize_import, + ), ThreadPoolExecutor(max_workers=2) as executor: + responses = list(executor.map(import_schema, self.clients)) + + self.assertEqual([response.status_code for response in responses], [200, 200]) + strings = models.Splocaleitemstr.objects.filter( + containername=self.container, + language='en', + country__iexact='us', + ).filter(variant__isnull=True) + self.assertEqual(strings.count(), 1) + self.assertEqual(strings.get().text, 'Imported Accession') diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 4bcc30bedc7..dcbf54887b5 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -541,23 +541,27 @@ def _schema_import_string(operations, parent, parent_field, text, language, coun country_filter = Q(country__isnull=True) | Q(country='') if country is not None: country_filter = Q(country__iexact=country) - string = Splocaleitemstr.objects.filter( - **{parent_field: parent, 'language': language} - ).filter(country_filter).filter( - Q(variant='') | Q(variant__isnull=True) - ).order_by('-id').first() - if string is None: - operations.append(( - 'POST', Splocaleitemstr, None, - { - 'text': text, - 'language': language, - 'country': country, - parent_field: uri_for_model(parent.__class__, parent.id), - }, - )) - elif string.text != text: - operations.append(('PUT', Splocaleitemstr, string, {'text': text})) + with transaction.atomic(): + locked_parent = parent.__class__.objects.select_for_update().get(pk=parent.pk) + string = Splocaleitemstr.objects.filter( + **{parent_field: locked_parent, 'language': language} + ).filter(country_filter).filter( + Q(variant='') | Q(variant__isnull=True) + ).order_by('-id').first() + if string is None: + operations.append(( + 'POST', Splocaleitemstr, None, + { + 'text': text, + 'language': language, + 'country': country, + parent_field: uri_for_model( + locked_parent.__class__, locked_parent.id + ), + }, + )) + elif string.text != text: + operations.append(('PUT', Splocaleitemstr, string, {'text': text})) def _schema_import_operations(collection, schema, language, references=None): @@ -628,43 +632,41 @@ def schema_localization_import(request): r'[A-Za-z]{2}(?:-[A-Za-z]{2})?', language ): raise ValueError - operations = _schema_import_operations( - request.specify_collection, - schema, - language, - { - 'format': _schema_import_resource_names( - request.specify_collection, request.specify_user, - 'DataObjFormatters', './/format' - ), - 'picklistname': { - name.lower() for name in Picklist.objects.filter( - collection=request.specify_collection - ).values_list('name', flat=True) - }, - 'weblinkname': _schema_import_resource_names( - request.specify_collection, request.specify_user, - 'WebLinks', './/weblinkdef/name' - ), + references = { + 'format': _schema_import_resource_names( + request.specify_collection, request.specify_user, + 'DataObjFormatters', './/format' + ), + 'picklistname': { + name.lower() for name in Picklist.objects.filter( + collection=request.specify_collection + ).values_list('name', flat=True) }, - ) - except (AttributeError, KeyError, TypeError, ValueError, json.JSONDecodeError): - return HttpResponseBadRequest() + 'weblinkname': _schema_import_resource_names( + request.specify_collection, request.specify_user, + 'WebLinks', './/weblinkdef/name' + ), + } - from specifyweb.specify.api.crud import post_resource, put_resource + from specifyweb.specify.api.crud import post_resource, put_resource - with transaction.atomic(): - for method, model, resource, data in operations: - if method == 'PUT': - put_resource( - request.specify_collection, request.specify_user_agent, - model.__name__, resource.id, resource.version, data - ) - else: - post_resource( - request.specify_collection, request.specify_user_agent, - model.__name__, data - ) + with transaction.atomic(): + operations = _schema_import_operations( + request.specify_collection, schema, language, references + ) + for method, model, resource, data in operations: + if method == 'PUT': + put_resource( + request.specify_collection, request.specify_user_agent, + model.__name__, resource.id, resource.version, data + ) + else: + post_resource( + request.specify_collection, request.specify_user_agent, + model.__name__, data + ) + except (AttributeError, KeyError, TypeError, ValueError, json.JSONDecodeError): + return HttpResponseBadRequest() return JsonResponse({'updated': len(operations)}) view_parameters_schema = [ From 6d19ebc3beb3cc5b6f046d4f9b9a727898afd684 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Thu, 24 Sep 2026 13:21:42 +0200 Subject: [PATCH 19/26] Feat: Separate import errors from post-import refresh errors. --- .../lib/components/SchemaConfig/Layout.tsx | 28 +++++++++++++++++-- .../js_src/lib/localization/schema.ts | 10 +++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index f70b220de9d..0ba15154cb1 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -56,6 +56,7 @@ function SchemaConfigLayoutContent(): JSX.Element { const loading = React.useContext(LoadingContext); const [importFile, setImportFile] = React.useState(); const [importError, setImportError] = React.useState(false); + const [importRefreshError, setImportRefreshError] = React.useState(false); React.useEffect(() => { setSingleResource(`/specify/schema-config/${rawLanguage}/`); @@ -96,8 +97,13 @@ function SchemaConfigLayoutContent(): JSX.Element { errorMode: 'silent', }) ) - .then(() => handleSchemaSaved(rawLanguage, tableName)) - .catch(() => setImportError(true)) + .then( + () => + handleSchemaSaved(rawLanguage, tableName).catch(() => + setImportRefreshError(true) + ), + () => setImportError(true) + ) ); }; @@ -148,7 +154,7 @@ function SchemaConfigLayoutContent(): JSX.Element { schemaConfig: schemaText.schemaConfig(), })}

-

{schemaText.importSchemaLimitations()}

+

{schemaText.importSchemaLimitations()}

{schemaText.importSchemaBackupPrompt({ schemaConfig: schemaText.schemaConfig(), @@ -174,6 +180,22 @@ function SchemaConfigLayoutContent(): JSX.Element {

)} + {importRefreshError && ( + globalThis.location.reload()}> + {schemaText.reloadSchemaConfig()} + + } + icon={dialogIcons.success} + header={schemaText.importSchemaSuccess({ + schemaConfig: schemaText.schemaConfig(), + })} + onClose={(): void => setImportRefreshError(false)} + > +

{schemaText.importSchemaRefreshError()}

+
+ )} ); } diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index fc39f3b2ab2..8d8ace8ff5d 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -34,6 +34,16 @@ export const schemaText = createDictionary({ 'en-us': 'The {schemaConfig:string} export provided is invalid and cannot be imported.', }, + importSchemaSuccess: { + 'en-us': '{schemaConfig:string} imported successfully', + }, + importSchemaRefreshError: { + 'en-us': + 'The import succeeded, but the page could not refresh automatically. Reload the page to see the imported changes.', + }, + reloadSchemaConfig: { + 'en-us': 'Reload Page', + }, table: { 'en-us': 'Table', 'ru-ru': 'Стол', From d9758f661a6366e81d7eefede6006656e3619a3b Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Thu, 24 Sep 2026 13:36:48 +0200 Subject: [PATCH 20/26] Feat: Reject imports with a source-language mismatch --- .../tests/test_schema_localization_import.py | 54 +++++++++++++++++++ specifyweb/backend/context/views.py | 19 +++++-- .../components/SchemaConfig/Components.tsx | 1 + .../lib/components/SchemaConfig/Layout.tsx | 2 + .../js_src/lib/localization/schema.ts | 6 ++- 5 files changed, 77 insertions(+), 5 deletions(-) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index ad3ac8dd061..39bd1328a76 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -24,6 +24,16 @@ def setUp(self): self.client.force_login(self.specifyuser) self.client.cookies['collection'] = str(self.collection.id) + def test_export_includes_source_language(self): + response = self.client.get( + '/context/schema_localization.json?lang=en-US&export=true' + ) + + self.assertEqual(response.status_code, 200) + export = response.json() + self.assertEqual(export['language'], 'en-us') + self.assertIn('accession', export['schema']) + def test_imports_schema_values_and_skips_unknown_entries(self): response = self.client.post( '/context/schema_localization_import.json', @@ -123,6 +133,50 @@ def test_import_updates_existing_case_insensitive_country_string(self): string.refresh_from_db() self.assertEqual(string.text, 'Updated US Accession') + def test_imports_export_when_source_language_matches(self): + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'language': 'en-US', + 'schema': { + 'language': 'en-us', + 'schema': {'accession': {'name': 'Imported Accession'}}, + }, + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + models.Splocaleitemstr.objects.get( + containername=self.container, + language='en', + country='us', + ).text, + 'Imported Accession', + ) + + def test_rejects_export_when_source_language_differs(self): + response = self.client.post( + '/context/schema_localization_import.json', + data=json.dumps({ + 'language': 'fr', + 'schema': { + 'language': 'en', + 'schema': {'accession': {'name': 'Should Not Import'}}, + }, + }), + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse( + models.Splocaleitemstr.objects.filter( + containername=self.container, + text='Should Not Import', + ).exists() + ) + def test_invalid_values_do_not_write(self): response = self.client.post( '/context/schema_localization_import.json', diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index dcbf54887b5..9b212e4416a 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -476,8 +476,13 @@ def schema_localization(request): form ll[-cc] where ll is a language code and cc is an optional country code. """ - lang = request.GET.get('lang', request.LANGUAGE_CODE) - return JsonResponse(get_schema_localization(request.specify_collection, 0, lang)) + lang = request.GET.get('lang', request.LANGUAGE_CODE).lower() + schema = get_schema_localization(request.specify_collection, 0, lang) + return JsonResponse( + {'language': lang, 'schema': schema} + if request.GET.get('export') == 'true' + else schema + ) SCHEMA_IMPORT_FIELDS = { @@ -487,6 +492,7 @@ def schema_localization(request): }, } SCHEMA_IMPORT_BOOLEAN_FIELDS = {'ishidden', 'isrequired'} +SCHEMA_IMPORT_NULLABLE_BOOLEAN_FIELDS = {'isrequired'} SCHEMA_IMPORT_TABLE_KEYS = { 'items', 'name', 'desc', *SCHEMA_IMPORT_FIELDS[Splocalecontainer] } @@ -516,8 +522,6 @@ def _schema_import_values(data, fields, references): key = key.lower() if key not in fields: continue -SCHEMA_IMPORT_NULLABLE_BOOLEAN_FIELDS = {'isrequired'} - if key in SCHEMA_IMPORT_BOOLEAN_FIELDS: if type(value) is not bool and not ( value is None and key in SCHEMA_IMPORT_NULLABLE_BOOLEAN_FIELDS @@ -632,6 +636,13 @@ def schema_localization_import(request): r'[A-Za-z]{2}(?:-[A-Za-z]{2})?', language ): raise ValueError + if isinstance(schema, dict) and {'language', 'schema'} <= schema.keys(): + source_language = schema['language'] + if not isinstance(source_language, str) or ( + source_language.lower() != language.lower() + ): + raise ValueError + schema = schema['schema'] references = { 'format': _schema_import_resource_names( request.specify_collection, request.specify_user, diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx index eb305e0ec73..b607372bf55 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx @@ -42,6 +42,7 @@ export function SchemaConfigHeader({ download={`schema_localization_${rawLanguage}.json`} href={formatUrl('/context/schema_localization.json', { lang: rawLanguage, + export: 'true', })} > {commonText.export()} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index 0ba15154cb1..838214e9457 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -130,6 +130,7 @@ function SchemaConfigLayoutContent(): JSX.Element { download={`schema_localization_${rawLanguage}.json`} href={formatUrl('/context/schema_localization.json', { lang: rawLanguage, + export: 'true', })} > {schemaText.downloadSchemaBackup({ @@ -154,6 +155,7 @@ function SchemaConfigLayoutContent(): JSX.Element { schemaConfig: schemaText.schemaConfig(), })}

+

{schemaText.importSchemaLanguageWarning()}

{schemaText.importSchemaLimitations()}

{schemaText.importSchemaBackupPrompt({ diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 8d8ace8ff5d..21679996a81 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -16,6 +16,10 @@ export const schemaText = createDictionary({ 'en-us': 'Importing a {schemaConfig:string} will overwrite the current one. This action cannot be undone.', }, + importSchemaLanguageWarning: { + 'en-us': + 'The exported schema language must match the language currently being configured.', + }, importSchemaBackupPrompt: { 'en-us': 'We strongly recommend downloading a backup of the current {schemaConfig:string} before importing.', @@ -32,7 +36,7 @@ export const schemaText = createDictionary({ }, importSchemaError: { 'en-us': - 'The {schemaConfig:string} export provided is invalid and cannot be imported.', + 'The {schemaConfig:string} export is invalid or was exported for a different language and cannot be imported.', }, importSchemaSuccess: { 'en-us': '{schemaConfig:string} imported successfully', From 27e7cfcd7dc684bc7a02a5e6ce1b06a394f60084 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:45:50 +0200 Subject: [PATCH 21/26] Potential fix for pull request finding 'CodeQL / Empty except' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../backend/context/tests/test_schema_localization_import.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specifyweb/backend/context/tests/test_schema_localization_import.py b/specifyweb/backend/context/tests/test_schema_localization_import.py index 39bd1328a76..f62fbffb13b 100644 --- a/specifyweb/backend/context/tests/test_schema_localization_import.py +++ b/specifyweb/backend/context/tests/test_schema_localization_import.py @@ -261,6 +261,8 @@ def synchronize_import(*args): try: barrier.wait(timeout=1) except threading.BrokenBarrierError: + # Best-effort synchronization for concurrency overlap in this test; + # if one worker times out or exits early, continue without failing here. pass def import_schema(client): From c83e2a46267dd8de6f1750141b9d56733013a69a Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Thu, 24 Sep 2026 14:07:18 +0200 Subject: [PATCH 22/26] Fix: Filter imported aggregators against DataObjFormatters. --- specifyweb/backend/context/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 9b212e4416a..644e127cccd 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -648,6 +648,10 @@ def schema_localization_import(request): request.specify_collection, request.specify_user, 'DataObjFormatters', './/format' ), + 'aggregator': _schema_import_resource_names( + request.specify_collection, request.specify_user, + 'DataObjFormatters', './/aggregator' + ), 'picklistname': { name.lower() for name in Picklist.objects.filter( collection=request.specify_collection From 46a3579651ebb8d7f6d3bff3ec7e4cca1653d964 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Thu, 24 Sep 2026 14:09:43 +0200 Subject: [PATCH 23/26] Fix: Validate item formats against UIFormatters. --- specifyweb/backend/context/views.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 644e127cccd..8ec155baf82 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -593,8 +593,10 @@ def _schema_import_operations(collection, schema, language, references=None): if not isinstance(table_data, dict): raise ValueError values = _schema_import_values( - table_data, SCHEMA_IMPORT_FIELDS[Splocalecontainer], references + table_data, SCHEMA_IMPORT_FIELDS[Splocalecontainer], + {**references, 'format': references['itemformat']} ) + if values: operations.append(('PUT', Splocalecontainer, container, values)) _schema_import_string( @@ -648,6 +650,10 @@ def schema_localization_import(request): request.specify_collection, request.specify_user, 'DataObjFormatters', './/format' ), + 'itemformat': _schema_import_resource_names( + request.specify_collection, request.specify_user, + 'UIFormatters', './/format' + ), 'aggregator': _schema_import_resource_names( request.specify_collection, request.specify_user, 'DataObjFormatters', './/aggregator' From 13760c1d78735b21d71e14b50f95b75bbfe1b985 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Thu, 24 Sep 2026 14:27:16 +0200 Subject: [PATCH 24/26] Fix: Use the correct formatter set for each schema level. --- specifyweb/backend/context/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 8ec155baf82..424c3612132 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -593,8 +593,7 @@ def _schema_import_operations(collection, schema, language, references=None): if not isinstance(table_data, dict): raise ValueError values = _schema_import_values( - table_data, SCHEMA_IMPORT_FIELDS[Splocalecontainer], - {**references, 'format': references['itemformat']} + table_data, SCHEMA_IMPORT_FIELDS[Splocalecontainer], references ) if values: @@ -614,8 +613,8 @@ def _schema_import_operations(collection, schema, language, references=None): if item is None: continue values = _schema_import_values( - item_data, SCHEMA_IMPORT_FIELDS[Splocalecontaineritem], references - ) + item_data, SCHEMA_IMPORT_FIELDS[Splocalecontaineritem], + {**references, 'format': references['itemformat']} ) if values: operations.append(('PUT', Splocalecontaineritem, item, values)) _schema_import_string( From a2db7e8f616f28c0ac78344cbe6a6ea60d272dce Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Thu, 24 Sep 2026 15:26:00 +0200 Subject: [PATCH 25/26] Fix: Enforce the Schema Config tool permission on the import endpoint. --- specifyweb/backend/context/views.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 424c3612132..2e464f96f73 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -28,7 +28,7 @@ from specifyweb.backend.permissions.permissions import PermissionTarget, \ PermissionTargetAction, \ check_permission_targets, skip_collection_access_check, query_pt, \ - CollectionAccessPT + CollectionAccessPT, enforce from specifyweb.specify.models import Collection, Discipline, Division, Collectionobject, Institution, \ Specifyuser, Spprincipal, Spversion, Collectionobjecttype, Picklist, Splocalecontainer, \ Splocalecontaineritem, Splocaleitemstr @@ -629,6 +629,23 @@ def _schema_import_operations(collection, schema, language, references=None): @login_maybe_required @require_http_methods(['POST']) def schema_localization_import(request): + schema_config_tables = [ + '/table/splocalecontainer', + '/table/splocalecontaineritem', + '/table/splocaleitemstr', + ] + enforce( + request.specify_collection, + request.specify_user_agent, + schema_config_tables, + 'create', + ) + enforce( + request.specify_collection, + request.specify_user_agent, + schema_config_tables, + 'update', + ) try: payload = json.loads(request.body) schema = payload.get('schema', payload) From 615780fd323d870462ef110a603728204de26338 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:23:28 +0200 Subject: [PATCH 26/26] Update specifyweb/backend/context/views.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- specifyweb/backend/context/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index 2e464f96f73..ed50095bf07 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -496,7 +496,9 @@ def schema_localization(request): SCHEMA_IMPORT_TABLE_KEYS = { 'items', 'name', 'desc', *SCHEMA_IMPORT_FIELDS[Splocalecontainer] } -SCHEMA_IMPORT_REFERENCE_FIELDS = {'format', 'picklistname', 'weblinkname'} +SCHEMA_IMPORT_REFERENCE_FIELDS = { + 'format', 'aggregator', 'picklistname', 'weblinkname', +} def _schema_import_resource_names(collection, user, resource, path):