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
@@ -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);
+ }}
+ />
+
{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={+
{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({