Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5e92876
feat(schema-config): add atomic schema import endpoint
grantfitzsimmons Aug 25, 2026
e5c70d7
feat(schema-config): add schema import controls
grantfitzsimmons Aug 25, 2026
4c1fe2b
fix(schema-config): tolerate legacy import values
grantfitzsimmons Aug 25, 2026
29a7568
fix(schema-config): build localized import parents
grantfitzsimmons Aug 25, 2026
31afb51
fix(schema-config): validate import file structure
grantfitzsimmons Aug 25, 2026
714f512
refactor(schema-config): reuse localized schema config label
grantfitzsimmons Aug 26, 2026
527de3f
fix(schema-config): skip unavailable import references
grantfitzsimmons Aug 26, 2026
1c608a0
fix(schema-config): validate import language
grantfitzsimmons Aug 26, 2026
5279a97
Lint code with ESLint and Prettier
grantfitzsimmons Aug 26, 2026
4a65bd4
fix(schema-config): contain invalid import errors
grantfitzsimmons Aug 26, 2026
c9f5de4
Merge branch 'issue-6155-2' of https://github.com/specify/specify7 in…
grantfitzsimmons Aug 26, 2026
41379d2
fix(schema-config): separate import warnings
grantfitzsimmons Aug 26, 2026
ac5c556
fix(schema-config): finalize text
grantfitzsimmons Aug 26, 2026
56be230
Lint code with ESLint and Prettier
grantfitzsimmons Aug 26, 2026
00f4b02
fix schema localization import review comments
grantfitzsimmons Aug 26, 2026
abb176a
Merge remote-tracking branch 'origin/main' into issue-6155-2
Copilot Sep 2, 2026
f69ccaa
Lint code with ESLint and Prettier
Copilot Sep 2, 2026
e9ccbfc
Fix: Normalize locale keys before the lookup
CarolineDenis Sep 7, 2026
2664772
Merge branch 'main' into issue-6155-2
melton-jason Sep 15, 2026
99f9d05
Update specifyweb/backend/context/views.py
CarolineDenis Sep 24, 2026
ef3cee2
Fix: Serialize localized-string lookup and creation.
CarolineDenis Sep 24, 2026
6d19ebc
Feat: Separate import errors from post-import refresh errors.
CarolineDenis Sep 24, 2026
d9758f6
Feat: Reject imports with a source-language mismatch
CarolineDenis Sep 24, 2026
27e7cfc
Potential fix for pull request finding 'CodeQL / Empty except'
CarolineDenis Sep 24, 2026
c83e2a4
Fix: Filter imported aggregators against DataObjFormatters.
CarolineDenis Sep 24, 2026
46a3579
Fix: Validate item formats against UIFormatters.
CarolineDenis Sep 24, 2026
13760c1
Fix: Use the correct formatter set for each schema level.
CarolineDenis Sep 24, 2026
a2db7e8
Fix: Enforce the Schema Config tool permission on the import endpoint.
CarolineDenis Sep 24, 2026
615780f
Update specifyweb/backend/context/views.py
CarolineDenis Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 292 additions & 0 deletions specifyweb/backend/context/tests/test_schema_localization_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
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, ApiTransactionTests


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_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',
data=json.dumps({
'language': 'en',
'schema': {
'accession': {
'format': 'Accession',
'name': 'Imported Accession',
'items': {
'accessionnumber': {
'isHidden': True,
'name': 'Imported Number',
'pickListName': 'Unavailable Picklist',
'webLinkName': 'Unavailable Web Link',
},
'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, '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'
).text,
'Imported Accession',
)
self.assertEqual(
models.Splocaleitemstr.objects.get(
itemname=self.item, language='en'
).text,
'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_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',
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)

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)

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',
'schema': {'accession': 'invalid'},
}),
content_type='application/json',
)

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:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
# 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):
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')
2 changes: 1 addition & 1 deletion specifyweb/backend/context/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
Defines the urls for the app context subsystem
"""

from django.urls import path
from django.urls import path

from . import views, user_resources, collection_resources
Expand Down Expand Up @@ -30,6 +29,7 @@
path('viewsets.json', views.viewsets),
path('datamodel.json', views.datamodel),
path('schema_localization.json', views.schema_localization),
path('schema_localization_import.json', views.schema_localization_import),
path('app.resource', views.app_resource),
path('available_related_searches.json', views.available_related_searches),
path('remoteprefs.properties', views.remote_prefs),
Expand Down
Loading
Loading