diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 9e7137026bf..d77504b923a 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -25,7 +25,12 @@ import type { } from '../TreeView/CreateTree'; import { PopulatedTreeList } from '../TreeView/CreateTree'; import type { FieldConfig, ResourceConfig } from './setupResources'; -import { disciplineTypeOptions, FIELD_MAX_LENGTH, resources,stepOrder } from './setupResources'; +import { + disciplineTypeOptions, + FIELD_MAX_LENGTH, + resources, + stepOrder, +} from './setupResources'; import type { ResourceFormData } from './types'; function getFormValue( @@ -142,15 +147,20 @@ export function renderFormFieldFactory({ const verticalSpacing = width !== undefined && width < 2 ? '-mb-2' : 'mb-2'; - const disciplineTypeValue = getFormValue(formData, stepOrder.indexOf('discipline'), 'type'); + const disciplineTypeValue = getFormValue( + formData, + stepOrder.indexOf('discipline'), + 'type' + ); const isDisciplineNameDisabled = resources[currentStep].resourceName === 'discipline' && fieldName === 'name' && (disciplineTypeValue === undefined || disciplineTypeValue === ''); const isRowEnableToggle = name === 'include'; - const isRowEnabled = - Boolean(getFormValue(formData, currentStep, `${parentName}.include`)); + const isRowEnabled = Boolean( + getFormValue(formData, currentStep, `${parentName}.include`) + ); const taxonTreeAvailable = Array.isArray(treeOptions) && @@ -177,9 +187,7 @@ export function renderFormFieldFactory({ getFormValue(formData, currentStep, fieldName) )} disabled={ - inTable - ? !isRowEnabled && !isRowEnableToggle - : false + inTable ? !isRowEnabled && !isRowEnableToggle : false } id={fieldName} name={fieldName} @@ -325,7 +333,7 @@ export function renderFormFieldFactory({ { handleChange(fieldName, resource); handleChange('preload', true); @@ -351,9 +359,14 @@ export function renderFormFieldFactory({ value={getFormValue(formData, currentStep, fieldName) ?? ''} onChange={({ target }) => { // Only allow unique discipline names - if (resources[currentStep].resourceName === 'discipline' && fieldName === 'name') { + if ( + resources[currentStep].resourceName === 'discipline' && + fieldName === 'name' + ) { const value = (target.value ?? '').trim(); - const isUnique = institutionData.children.some((child) => child.name === value); + const isUnique = institutionData.children.some( + (child) => child.name === value + ); target.setCustomValidity( isUnique ? '' : formsText.valueMustBeUniqueToDatabase() ); @@ -438,7 +451,7 @@ export function updateSetupFormData( name: string, newValue: LocalizedString | TaxonFileDefaultDefinition | boolean, currentStep: number, - institutionData?: InstitutionData, + institutionData?: InstitutionData ): void { setFormData((previous: ResourceFormData) => { const resourceName = resources[currentStep].resourceName; @@ -454,10 +467,15 @@ export function updateSetupFormData( (option) => option.value === newValue ); if (matchingType) { - const existingDisciplines = institutionData ? institutionData.children.flatMap(division => - division.children.map(discipline => discipline.name) - ) : []; - const disciplineName = getUniqueName(matchingType.label, existingDisciplines); + const existingDisciplines = institutionData + ? institutionData.children.flatMap((division) => + division.children.map((discipline) => discipline.name) + ) + : []; + const disciplineName = getUniqueName( + matchingType.label, + existingDisciplines + ); updates.name = matchingType ? disciplineName : ''; } @@ -482,4 +500,4 @@ export function updateSetupFormData( [resourceName]: updates, }; }); -} \ No newline at end of file +} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/types.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/types.ts index e63de262760..0cf046ca7d6 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/types.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/types.ts @@ -21,4 +21,4 @@ export type SetupResponse = { readonly success: boolean; readonly setup_progress: SetupProgress; readonly task_id: string; -}; \ No newline at end of file +}; diff --git a/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx b/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx index 6c7a7b0801e..48fdaaeed33 100644 --- a/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx +++ b/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx @@ -676,7 +676,9 @@ export function Hierarchy({ {addButton( // Use custom forms for discipline to allow tree configuration () => { - setFormData(Object.fromEntries(stepOrder.map((key) => [key, {}]))); + setFormData( + Object.fromEntries(stepOrder.map((key) => [key, {}])) + ); openDisciplineCreation(); setDisciplineStep(0); }, diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx index ff1cfe10c40..1397728dad6 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { commonText } from '../../localization/common'; -import { treeText } from '../../localization/tree'; import { queryText } from '../../localization/query'; +import { treeText } from '../../localization/tree'; import { ajax } from '../../utils/ajax'; import { Http } from '../../utils/ajax/definitions'; import { ping } from '../../utils/ajax/ping'; @@ -200,8 +200,8 @@ export function ImportTree({ readonly tableName: SCHEMA['tableName']; readonly treeDefId: number; readonly treeDefinitionItems: RA< - SerializedResource> - >; + SerializedResource> + >; }): JSX.Element { const loading = React.useContext(LoadingContext); const [isActive, setIsActive] = React.useState(0); @@ -211,9 +211,14 @@ export function ImportTree({ string | undefined >(undefined); - const [missingTreeRanks, setMissingTreeRanks] = React.useState | undefined>(undefined); - const [isMissingTreeRanks, setIsMissingTreeRanks] = React.useState(false); - const [selectedPopulatedTree, setSelectedPopulatedTree] = React.useState(undefined); + const [missingTreeRanks, setMissingTreeRanks] = React.useState< + RA | undefined + >(undefined); + const [isMissingTreeRanks, setIsMissingTreeRanks] = + React.useState(false); + const [selectedPopulatedTree, setSelectedPopulatedTree] = React.useState< + TaxonFileDefaultDefinition | undefined + >(undefined); const connectedCollection = getSystemInfo().collection; @@ -225,21 +230,26 @@ export function ImportTree({ // Check for missing ranks if no preference for createMissingRanks was provided. console.log(createMissingRanks); if (createMissingRanks === undefined) { - console.log("finding if theres missing ranks"); + console.log('finding if theres missing ranks'); try { const response = await ajax(`/trees/default_tree_mapping/`, { method: 'POST', headers: { Accept: 'application/json' }, body: { - mappingUrl: resource.mappingFile - } + mappingUrl: resource.mappingFile, + }, }); if (response.status === Http.OK && response.data) { - const mappingRankNames = response.data.ranks.map((rank: any) => {return rank.name}); - const existingNames = treeDefinitionItems.map((item) => item.name); + const mappingRankNames = response.data.ranks.map( + (rank: any) => rank.name + ); + const existingNames = new Set( + treeDefinitionItems.map((item) => item.name) + ); - const missing = mappingRankNames - .filter((rankName: string) => !existingNames.includes(rankName)); + const missing = mappingRankNames.filter( + (rankName: string) => !existingNames.has(rankName) + ); if (missing.length > 0) { setSelectedPopulatedTree(resource); @@ -251,8 +261,8 @@ export function ImportTree({ } else { console.warn(`Failed to fetch mapping for ${resource.mappingFile}`); } - } catch (err) { - console.warn('Error fetching or parsing mapping file', err); + } catch (error) { + console.warn('Error fetching or parsing mapping file', error); } } @@ -291,10 +301,10 @@ export function ImportTree({ setIsMissingTreeRanks(false); handleClick(selectedPopulatedTree, true); }} + missingTreeRanks={missingTreeRanks} onClose={() => { setIsMissingTreeRanks(false); }} - missingTreeRanks={missingTreeRanks} /> ) : null} {isActive === 1 ? ( @@ -345,7 +355,7 @@ async function startTreeCreation( treeName: string, treeDefId: number | undefined, createMissingRanks: boolean | undefined, - onSuccess: (taskId: string | undefined) => void, + onSuccess: (taskId: string | undefined) => void ): Promise { return ajax('/trees/create_default_tree/', { method: 'POST', @@ -366,10 +376,7 @@ async function startTreeCreation( console.log(`${treeName} tree created successfully:`, data); } else if (status === Http.ACCEPTED) { // Tree is being created in the background. - console.log( - `${treeName} tree creation started successfully:`, - data - ); + console.log(`${treeName} tree creation started successfully:`, data); onSuccess(data.task_id); } }) @@ -471,18 +478,14 @@ export function MissingTreeRanksDialog({ {commonText.no()} - - {queryText.yes()} - + {queryText.yes()} } header={treeText.missingRanks()} onClose={onClose} >
-
- {treeText.missingRanksDescription()} -
+
{treeText.missingRanksDescription()}
    {missingTreeRanks && missingTreeRanks.length > 0 @@ -490,9 +493,7 @@ export function MissingTreeRanksDialog({ : null}
-
- {treeText.createMissingRanks()} -
+
{treeText.createMissingRanks()}
); diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index b090402fc2e..ced11a0a72a 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -226,7 +226,11 @@ export function Tree< onClick={createRootNode} /> {treeDefId ? ( - + ) : null} ) : undefined} diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index 7be1018d230..7c20fc94423 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -731,7 +731,8 @@ export const treeText = createDictionary({ 'en-us': 'Missing Ranks', }, missingRanksDescription: { - 'en-us': 'The populated tree you selected to download contains records in ranks that are missing from your tree.', + 'en-us': + 'The populated tree you selected to download contains records in ranks that are missing from your tree.', }, createMissingRanks: { 'en-us': 'Should the missing ranks be created?', diff --git a/specifyweb/specify/management/commands/sync_schema_config_fields.py b/specifyweb/specify/management/commands/sync_schema_config_fields.py new file mode 100644 index 00000000000..5e01671d961 --- /dev/null +++ b/specifyweb/specify/management/commands/sync_schema_config_fields.py @@ -0,0 +1,82 @@ +from django.core.management.base import BaseCommand, CommandError +from django.apps import apps + +from specifyweb.specify.migration_utils import update_schema_config as update_schema + + +class Command(BaseCommand): + help = "Finds missing schema config fields for a discipline and can create them." + + def add_arguments(self, parser): + parser.add_argument( + "--discipline-id", + type=int, + dest="discipline_id", + required=True, + help="Discipline ID to target.", + ) + parser.add_argument( + "--apply", + action="store_true", + dest="apply", + default=False, + help="Create any missing schema config records.", + ) + parser.add_argument( + "--verbose", + action="store_true", + dest="verbose", + default=False, + help="Print each create operation as it runs.", + ) + + def handle(self, **options): + discipline_id = options.get("discipline_id") + apply_changes = options.get("apply", False) + verbose = options.get("verbose", False) + + # Resolve the discipline by given ID + Discipline = apps.get_model("specify", "Discipline") + try: + discipline = Discipline.objects.get(id=discipline_id) + except Discipline.DoesNotExist as exc: + raise CommandError( + f"Discipline with ID {discipline_id} not found." + ) from exc + + self.stdout.write( + f"Discipline: {discipline.name} (ID={discipline.id})" + ) + + missing_tables, missing_fields = update_schema.find_missing_schema_config_fields(discipline.id, apps=apps,) + + if not missing_tables and not missing_fields: + self.stdout.write("No missing schema config fields found.") + return + + # Print out what would be created if applied. + if missing_tables: + self.stdout.write("Missing table containers:") + for table_name in sorted(missing_tables): + self.stdout.write(f"- {table_name}") + + if missing_fields: + self.stdout.write("Missing fields:") + for table_name in sorted(missing_fields.keys()): + field_names = missing_fields[table_name] + if not field_names: + continue + joined_fields = ", ".join(field_names) + self.stdout.write(f"- {table_name}: {joined_fields}") + + if not apply_changes: + self.stdout.write("Run again with --apply to create missing records.") + return + + # Apply changes + update_schema.create_missing_schema_config_fields( + discipline.id, + apps=apps, + stdout=self.stdout.write if verbose else None, + ) + self.stdout.write("Applied missing schema config records.") diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index dad859b5722..9db2f6866d7 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -11,7 +11,7 @@ from django.db import connection, transaction from django.db.models.functions import RowNumber -from specifyweb.specify.models_utils.load_datamodel import Table, FieldDoesNotExistError, TableDoesNotExistError +from specifyweb.specify.models_utils.load_datamodel import FieldDoesNotExistError, TableDoesNotExistError from specifyweb.specify.models_utils.model_extras import GEOLOGY_DISCIPLINES, PALEO_DISCIPLINES from specifyweb.specify.models import ( Discipline, @@ -445,6 +445,61 @@ def update_table_field_schema_config_params( setattr(sp_local_container_item, k, v) sp_local_container_item.save(update_fields=list(update_params.keys())) +def find_missing_schema_config_fields(discipline_id: int, apps=global_apps,): + Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') + Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') + + missing_tables: list[str] = [] + missing_fields: dict[str, list[str]] = {} + + for table in datamodel.tables: + table_name = table.name + containers = Splocalecontainer.objects.filter( + name=table_name.lower(), + discipline_id=discipline_id, + schematype=0, + ) + if not containers.exists(): + missing_tables.append(table_name) + missing_fields[table_name] = [ + field.name for field in table.all_fields if field.name + ] + continue + + missing_in_table: list[str] = [] + for field in table.all_fields: + if not field.name: + continue + if not Splocalecontaineritem.objects.filter( + container__in=containers, + name__iexact=field.name, + ).exists(): + missing_in_table.append(field.name) + + if missing_in_table: + missing_fields[table_name] = missing_in_table + + return missing_tables, missing_fields + + +def create_missing_schema_config_fields(discipline_id: int, apps=global_apps, stdout=None,): + missing_tables, missing_fields = find_missing_schema_config_fields(discipline_id,apps=apps,) + + for table_name in missing_tables: + if stdout is not None: + stdout(f"Creating schema config table container for {table_name}...") + update_table_schema_config_with_defaults(table_name, discipline_id, apps=apps,) + + for table_name, fields in missing_fields.items(): + if table_name in missing_tables: + continue + for field_name in fields: + if stdout is not None: + stdout(f"Creating schema config field {table_name}.{field_name}...") + update_table_field_schema_config_with_defaults(table_name, discipline_id, field_name, apps=apps,) + + return missing_tables, missing_fields + def deduplicate_schema_config_sql(apps=None): dedupe_sql = ''' /*