Skip to content
53 changes: 53 additions & 0 deletions config/common/schema_localization_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2062,6 +2062,59 @@
"name": "Common Name Tx Citation",
"desc": "Links a Reference Work to a Common Name record, citing the source where the common name was published or used."
},
"component": {
"items": {
"catalognumber": {"name": "Catalog Number", "desc": "User-assigned identifier for the component", "format": null, "ishidden": false, "isuiformatter": null, "picklistname": null, "type": "java.lang.String", "isrequired": false, "weblinkname": null},
"collectionobject": {"name": "Collection Object", "desc": "Collection Object", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "ManyToOne", "isrequired": true, "weblinkname": null},
"componentid": {"name": "Component Id", "desc": "Component Id", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": true, "weblinkname": null},
"createdbyagent": {"name": "Created By Agent", "desc": "Created By Agent", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "ManyToOne", "isrequired": false, "weblinkname": null},
"identifiedby": {"name": "Identified By", "desc": "Identified By", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "ManyToOne", "isrequired": false, "weblinkname": null},
"identifieddate": {"name": "Identified Date", "desc": "Identified Date", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.util.Date", "isrequired": false, "weblinkname": null},
"integer1": {"name": "Integer1", "desc": "Integer1", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"integer2": {"name": "Integer2", "desc": "Integer2", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"integer3": {"name": "Integer3", "desc": "Integer3", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"integer4": {"name": "Integer4", "desc": "Integer4", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"integer5": {"name": "Integer5", "desc": "Integer5", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"integer6": {"name": "Integer6", "desc": "Integer6", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"modifiedbyagent": {"name": "Modified By Agent", "desc": "Modified By Agent", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "ManyToOne", "isrequired": false, "weblinkname": null},
"name": {"name": "Name", "desc": "The name from a taxon tree corresponding to the chosen type.", "format": null, "ishidden": false, "isuiformatter": null, "picklistname": null, "type": "ManyToOne", "isrequired": false, "weblinkname": null},
"number1": {"name": "Number1", "desc": "Number1", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.math.BigDecimal", "isrequired": false, "weblinkname": null},
"number2": {"name": "Number2", "desc": "Number2", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.math.BigDecimal", "isrequired": false, "weblinkname": null},
"number3": {"name": "Number3", "desc": "Number3", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.math.BigDecimal", "isrequired": false, "weblinkname": null},
"number4": {"name": "Number4", "desc": "Number4", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.math.BigDecimal", "isrequired": false, "weblinkname": null},
"number5": {"name": "Number5", "desc": "Number5", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.math.BigDecimal", "isrequired": false, "weblinkname": null},
"number6": {"name": "Number6", "desc": "Number6", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.math.BigDecimal", "isrequired": false, "weblinkname": null},
"proportion": {"name": "Proportion", "desc": "Specify the proportion of the component relative to the whole.", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"role": {"name": "Role", "desc": "Define the role or purpose of the component in the overall collection.", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.String", "isrequired": true, "weblinkname": null},
"text1": {"name": "Text1", "desc": "Text1", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
"text2": {"name": "Text2", "desc": "Text2", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
"text3": {"name": "Text3", "desc": "Text3", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
"text4": {"name": "Text4", "desc": "Text4", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
"text5": {"name": "Text5", "desc": "Text5", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
"text6": {"name": "Text6", "desc": "Text6", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
"timestampcreated": {"name": "Timestamp Created", "desc": "Timestamp Created", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.sql.Timestamp", "isrequired": true, "weblinkname": null},
"timestampmodified": {"name": "Timestamp Modified", "desc": "Timestamp Modified", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.sql.Timestamp", "isrequired": false, "weblinkname": null},
"type": {"name": "Type", "desc": "Determines the valid options for component names.", "format": null, "ishidden": false, "isuiformatter": null, "picklistname": "CollectionObjectType", "type": "ManyToOne", "isrequired": true, "weblinkname": null},
"uniqueidentifier": {"name": "Unique Identifier", "desc": "Uniquely identifies each component record", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.String", "isrequired": false, "weblinkname": null},
"verbatimname": {"name": "Verbatim Name", "desc": "The original name printed or associated with the component.", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
"version": {"name": "Version", "desc": "Version", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Integer", "isrequired": false, "weblinkname": null},
"yesno1": {"name": "Yes No1", "desc": "Yes No1", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Boolean", "isrequired": false, "weblinkname": null},
"yesno2": {"name": "Yes No2", "desc": "Yes No2", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Boolean", "isrequired": false, "weblinkname": null},
"yesno3": {"name": "Yes No3", "desc": "Yes No3", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Boolean", "isrequired": false, "weblinkname": null},
"yesno4": {"name": "Yes No4", "desc": "Yes No4", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Boolean", "isrequired": false, "weblinkname": null},
"yesno5": {"name": "Yes No5", "desc": "Yes No5", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Boolean", "isrequired": false, "weblinkname": null},
"yesno6": {"name": "Yes No6", "desc": "Yes No6", "format": null, "ishidden": true, "isuiformatter": null, "picklistname": null, "type": "java.lang.Boolean", "isrequired": false, "weblinkname": null}
},
"format": null,
"ishidden": false,
"isuiformatter": null,
"picklistname": null,
"type": null,
"aggregator": null,
"defaultui": null,
"name": "Component",
"desc": "Represents a component part of a Collection Object, often featuring a unique number and/or taxonomic identification, with associated metadata like proportion, role, and identifiers."
},
"conservdescription": {
"items": {
"backgroundinfo": {"name": "Background Info", "desc": "Any background information on the object.", "format": null, "ishidden": false, "isuiformatter": false, "picklistname": null, "type": "text", "isrequired": false, "weblinkname": null},
Expand Down
108 changes: 108 additions & 0 deletions specifyweb/specify/tests/test_schema_localization_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import ast
import json
from pathlib import Path
from unittest import TestCase


# These tables are present in schema_localization_en.json but are not backed by
# Django model classes in models.py, so they need an explicit exception here.
ALLOWLISTED_JSON_ONLY_TABLES = {
'autonumsch_coll',
'autonumsch_div',
'autonumsch_dsp',
'dnasequencingrunattachment',
'libraryrole',
'libraryrolepolicy',
'message',
'project_colobj',
'role',
'rolepolicy',
'sp_schema_mapping',
'spattachmentdataset',
'spdataset',
'spdatasetattachment',
'specifyuser_spprincipal',
'spmerging',
'spprincipal_sppermission',
'spuserexternalid',
'uniquenessrule',
'uniquenessrulefield',
'userpolicy',
'userrole',
}


class SchemaLocalizationContractTests(TestCase):
"""Ensure schema localization entries cover the model-backed tables."""

@classmethod
def setUpClass(cls):
cls.repo_root = Path(__file__).resolve().parents[3]
cls.localization_path = cls.repo_root / 'config' / 'common' / 'schema_localization_en.json'
cls.models_path = cls.repo_root / 'specifyweb' / 'specify' / 'models.py'

with cls.localization_path.open(encoding='utf-8') as handle:
cls.localization = json.load(handle)

cls.model_tables = cls._extract_model_tables()

@classmethod
def _extract_model_tables(cls):
source = cls.models_path.read_text(encoding='utf-8')
module = ast.parse(source)
tables = {}

for node in module.body:
if not isinstance(node, ast.ClassDef):
continue

for child in node.body:
if isinstance(child, ast.ClassDef) and child.name == 'Meta':
for stmt in child.body:
if isinstance(stmt, ast.Assign):
for target in stmt.targets:
if isinstance(target, ast.Name) and target.id == 'db_table':
if isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str):
table = stmt.value.value.lower()
tables.setdefault(table, set()).add(node.name)
break

return tables

def test_model_tables_have_schema_localization_entries(self):
json_keys = set(self.localization.keys())
model_tables = set(self.model_tables)

missing = sorted(model_tables - json_keys)
extra = sorted(json_keys - model_tables)

unexpected_missing = [table for table in missing if table not in ALLOWLISTED_JSON_ONLY_TABLES]
unexpected_extra = [table for table in extra if table not in ALLOWLISTED_JSON_ONLY_TABLES]

message_parts = []
if unexpected_missing:
message_parts.append(
'Missing schema localization entries for model-backed tables:\n'
+ '\n'.join(
f'{table}: {", ".join(sorted(self.model_tables[table]))}'
for table in unexpected_missing
)
)

if unexpected_extra:
message_parts.append(
'Unexpected schema localization-only tables:\n'
+ '\n'.join(unexpected_extra)
)

if message_parts:
summary = (
f'Missing tables: {len(unexpected_missing)}; '
f'Unexpected tables: {len(unexpected_extra)}'
)
self.fail(
summary
+ '\n\n'
+ '\n\n'.join(message_parts)
+ '\n\nAdd entries to the `schema_localization_en.json` file or update the `allowlisted_json_only_tables` allowlist.'
)
Loading