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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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,