From 1bc2601080eed278115f7910e5efaaa7c7e40b19 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 7 Jan 2026 16:33:46 -0600 Subject: [PATCH 01/52] Speed up schema config creation --- .../backend/setup_tool/schema_defaults.py | 35 +-- .../migration_utils/update_schema_config.py | 226 ++++++++++++++++++ 2 files changed, 246 insertions(+), 15 deletions(-) diff --git a/specifyweb/backend/setup_tool/schema_defaults.py b/specifyweb/backend/setup_tool/schema_defaults.py index 5c4240a834e..a5712310ca7 100644 --- a/specifyweb/backend/setup_tool/schema_defaults.py +++ b/specifyweb/backend/setup_tool/schema_defaults.py @@ -1,6 +1,7 @@ from typing import Optional -from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id -from specifyweb.specify.migration_utils.update_schema_config import update_table_schema_config_with_defaults +# from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id +# from specifyweb.specify.migration_utils.update_schema_config import update_table_schema_config_with_defaults +from specifyweb.specify.migration_utils.update_schema_config import create_default_table_schema_config from .utils import load_json_from_file from specifyweb.specify.models import Discipline @@ -38,18 +39,22 @@ def apply_schema_defaults(discipline: Discipline): defaults[key] = v # Update the schema for each table individually. - for model_name in model_names_by_table_id.values(): - logger.debug(f'Applying schema defaults for {model_name}. Using defaults: {overrides is not None}.') + # for model_name in model_names_by_table_id.values(): + # logger.debug(f'Applying schema defaults for {model_name}. Using defaults: {overrides is not None}.') - # Table information - table_defaults = defaults.get(model_name.lower()) - table_description = None - if table_defaults: - table_description = table_defaults.get('desc') + # # Table information + # table_defaults = defaults.get(model_name.lower()) + # table_description = None + # if table_defaults: + # table_description = table_defaults.get('desc') - update_table_schema_config_with_defaults( - table_name=model_name, - discipline_id=discipline.id, - description=table_description, - defaults=table_defaults, - ) \ No newline at end of file + # update_table_schema_config_with_defaults( + # table_name=model_name, + # discipline_id=discipline.id, + # description=table_description, + # defaults=table_defaults, + # ) + create_default_table_schema_config( + discipline_id=discipline.id, + defaults=defaults, + ) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 624aa993330..e1dc92a0bc1 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -84,6 +84,232 @@ class FieldSchemaConfig(NamedTuple): def uncapitilize(string: str) -> str: return string.lower() if len(string) <= 1 else string[0].lower() + string[1:] +from django.db import transaction +from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id + +def create_default_table_schema_config( + discipline_id: int, + apps = global_apps, + defaults: dict = None, +): + """Creates all schema config localization records. Assumes none exist.""" + Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') + Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') + Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') + + with transaction.atomic(): + container_batch = [] + item_batch = [] + item_str_batch = [] + + # These seemingly redundant loops are used for much needed batching + + # Create table containers # + for table_name in model_names_by_table_id.values(): + container_batch.append(create_table_container( + table_name, + discipline_id + )) + Splocalecontainer.objects.bulk_create(container_batch, ignore_conflicts=True) + + # Create a map to reference the saved parent containers later + saved_containers = Splocalecontainer.objects.filter( + name__in=[c.name for c in container_batch], + discipline_id=discipline_id + ) + container_map = {c.name: c for c in saved_containers} + + # Create items for all fields in every table # + for table_name in model_names_by_table_id.values(): + table = datamodel.get_table(table_name) + table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() + if table_defaults is None: + table_defaults = dict() + + key = table.name.lower() + sp_local_container = container_map[key] + + for field in table.all_fields: + field_defaults = None + if table_defaults.get('items'): + field_defaults = table_defaults['items'].get(field.name.lower()) + + field_name = field.name + item_batch.extend(create_field_item( + field_name, + table_name, + sp_local_container, + field_defaults + )) + + Splocalecontaineritem.objects.bulk_create(item_batch, ignore_conflicts=True) + + saved_items = Splocalecontaineritem.objects.filter( + container__name__in=[c.name for c in container_batch], + container__discipline_id=discipline_id + ) + item_map = {(i.container.name, i.name): i for i in saved_items} + + # Create strings for names and descriptions belonging all tables and their fields # + for table_name in model_names_by_table_id.values(): + table = datamodel.get_table(table_name) + table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() + if table_defaults is None: + table_defaults = dict() + + key = table.name.lower() + sp_local_container = container_map[key] + + item_str_batch.extend(create_table_strings( + field_name, + table_name, + sp_local_container, + field_defaults + )) + + for field in table.all_fields: + field_defaults = None + if table_defaults.get('items'): + field_defaults = table_defaults['items'].get(field.name.lower()) + + item_key = (sp_local_container.name, field_name) + sp_local_container_item = item_map[item_key] + + item_str_batch.extend( + create_field_strings( + field_name, + table_name, + sp_local_container_item, + field_defaults, + ) + ) + # 8. Bulk create itemstrs + Splocaleitemstr.objects.bulk_create(item_str_batch, ignore_conflicts=True) + +def create_table_container( + table_name: str, + discipline_id: int, + apps = global_apps, +): + Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') + table = datamodel.get_table(table_name) + + return Splocalecontainer( + name=table.name.lower(), + discipline_id=discipline_id, + schematype=0, + ishidden=False, + issystem=table.system, + version=0, + ) + +def create_field_item( + field_name: str, + table_name: str, + container, + field_defaults: dict = None, + apps = global_apps, +): + Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') + + table = datamodel.get_table(table_name) + field = table.get_field_strict(field_name) + field_name_str = camel_to_spaced_title_case(field.name) + field_desc_str = camel_to_spaced_title_case(field.name) + field_hidden = field_name.lower() in HIDDEN_FIELDS + field_required = field.required + picklist_name = None + if field_defaults is not None: + field_name_str = field_defaults.get('name', field_name_str) + field_desc_str = field_defaults.get('desc', field_desc_str) + field_hidden = field_defaults.get('ishidden', field_hidden) + field_required = field_defaults.get('isrequired', field_required) + picklist_name = field_defaults.get('picklistname', picklist_name) + + field_config = FieldSchemaConfig( + name=field_name, + column=field.column, + java_type=datamodel_type_to_schematype(field.type) if field.is_relationship else field.type, + description=field_desc_str, + language="en" + ) + + return Splocalecontaineritem( + name=field_config.name, + container=container, + type=field_config.java_type, + ishidden=field_hidden, + isrequired=field_required, + issystem=table.system, + version=0, + picklistname=picklist_name + ) + +def create_table_strings( + table_name: str, + container, + item, + table_defaults: dict = None, + apps = global_apps, +): + Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') + Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') + Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') + + table = datamodel.get_table(table_name) + table_name_str = table_defaults.get('name', camel_to_spaced_title_case(uncapitilize(table.name))) + table_desc_str = table_defaults.get('desc', camel_to_spaced_title_case(uncapitilize(table.name))) + + strings = [] + for k, text in { + "containername": table_name_str, + "containerdesc": table_desc_str, + }.items(): + item_str = { + "text": text, + "language": "en", + "version": 0, + } + item_str[k] = container + strings.append(Splocaleitemstr(**item_str)) + return strings + +def create_field_strings( + field_name: str, + table_name: str, + item, + field_defaults: dict = None, + apps = global_apps, +): + Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') + Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') + Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') + + table = datamodel.get_table(table_name) + field = table.get_field_strict(field_name) + field_name = field.name + field = table.get_field_strict(field_name) + field_name_str = camel_to_spaced_title_case(field.name) + field_desc_str = camel_to_spaced_title_case(field.name) + if field_defaults is not None: + field_name_str = field_defaults.get('name', field_name_str) + field_desc_str = field_defaults.get('desc', field_desc_str) + + strings = [] + for k, text in { + "itemname": field_name_str, + "itemdesc": field_desc_str, + }.items(): + itm_str = { + 'text': text, + 'language': 'en', + 'version': 0, + } + itm_str[k] = item + strings.append(Splocaleitemstr(**itm_str)) + + return strings + def update_table_schema_config_with_defaults( table_name, From 358430eae0aac8da770553579f765c17a25d276b Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 7 Jan 2026 22:37:58 +0000 Subject: [PATCH 02/52] Lint code with ESLint and Prettier Triggered by 1bc2601080eed278115f7910e5efaaa7c7e40b19 on branch refs/heads/issue-7615 --- .../js_src/lib/components/Login/index.tsx | 13 ++++++++-- .../js_src/lib/components/SetupTool/index.tsx | 4 +++- .../components/SetupTool/setupResources.ts | 24 ++++++++++++++++--- .../js_src/lib/components/SetupTool/utils.ts | 4 +++- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Login/index.tsx b/specifyweb/frontend/js_src/lib/components/Login/index.tsx index 23bed97e967..ae4c47afef3 100644 --- a/specifyweb/frontend/js_src/lib/components/Login/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Login/index.tsx @@ -51,8 +51,17 @@ export function Login(): JSX.Element { if (setupProgress === undefined) return ; - if (setupProgress.busy || (setupProgress.hasOwnProperty('resources') && Object.values(setupProgress.resources).includes(false))) { - return ; + if ( + setupProgress.busy || + (setupProgress.hasOwnProperty('resources') && + Object.values(setupProgress.resources).includes(false)) + ) { + return ( + + ); } return providers.length > 0 ? ( diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index ec516027073..d17d366ac68 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -315,7 +315,9 @@ export function SetupTool({ {commonText.back()} - {(currentStep === resources.length - 1) ? commonText.create() : commonText.next()} + {currentStep === resources.length - 1 + ? commonText.create() + : commonText.next()} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 436ba84d9d8..df4b4aa4ef0 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -172,7 +172,13 @@ export const resources: RA = [ type: 'object', // TODO: Rank fields should be generated from a .json file. fields: [ - { name: '0', label: 'Site', type: 'boolean', default: true, required: true }, + { + name: '0', + label: 'Site', + type: 'boolean', + default: true, + required: true, + }, { name: '100', label: 'Building', type: 'boolean' }, { name: '150', label: 'Collection', type: 'boolean' }, { name: '200', label: 'Room', type: 'boolean' }, @@ -235,7 +241,13 @@ export const resources: RA = [ required: false, type: 'object', fields: [ - { name: '0', label: 'Earth', type: 'boolean', default: true, required: true }, + { + name: '0', + label: 'Earth', + type: 'boolean', + default: true, + required: true, + }, { name: '100', label: 'Continent', type: 'boolean', default: true }, { name: '200', label: 'Country', type: 'boolean', default: true }, { name: '300', label: 'State', type: 'boolean', default: true }, @@ -270,7 +282,13 @@ export const resources: RA = [ required: false, type: 'object', fields: [ - { name: '0', label: 'Life', type: 'boolean', default: true, required: true }, + { + name: '0', + label: 'Life', + type: 'boolean', + default: true, + required: true, + }, { name: '10', label: 'Kingdom', type: 'boolean', default: true }, { name: '30', label: 'Phylum', type: 'boolean', default: true }, { name: '40', label: 'Subphylum', type: 'boolean', default: true }, diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts index f46de738669..ede2f791074 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts @@ -13,7 +13,9 @@ function flattenToNested(data: Record): Record { return result; } -export function flattenAllResources(data: Record): Record { +export function flattenAllResources( + data: Record +): Record { const result: Record = {}; Object.entries(data).forEach(([key, value]) => { result[key] = flattenToNested(value); From 5702189b23e6c8644a03ef1ada43c37ffbe936d3 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 12 Jan 2026 08:10:06 -0600 Subject: [PATCH 03/52] Split into smaller components --- .../migration_utils/update_schema_config.py | 76 ++++++++----------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index e1dc92a0bc1..b9ce60ec19f 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -84,6 +84,7 @@ class FieldSchemaConfig(NamedTuple): def uncapitilize(string: str) -> str: return string.lower() if len(string) <= 1 else string[0].lower() + string[1:] +from typing import NamedTuple, Literal from django.db import transaction from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id @@ -100,7 +101,7 @@ def create_default_table_schema_config( with transaction.atomic(): container_batch = [] item_batch = [] - item_str_batch = [] + str_batch = [] # These seemingly redundant loops are used for much needed batching @@ -121,24 +122,21 @@ def create_default_table_schema_config( # Create items for all fields in every table # for table_name in model_names_by_table_id.values(): - table = datamodel.get_table(table_name) table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() if table_defaults is None: table_defaults = dict() - key = table.name.lower() - sp_local_container = container_map[key] + container = container_map[table.name.lower()] for field in table.all_fields: field_defaults = None if table_defaults.get('items'): field_defaults = table_defaults['items'].get(field.name.lower()) - field_name = field.name item_batch.extend(create_field_item( - field_name, + field.name, table_name, - sp_local_container, + container, field_defaults )) @@ -157,34 +155,33 @@ def create_default_table_schema_config( if table_defaults is None: table_defaults = dict() - key = table.name.lower() - sp_local_container = container_map[key] + container = container_map[table.name.lower()] - item_str_batch.extend(create_table_strings( - field_name, + str_batch.extend(create_table_strings( table_name, - sp_local_container, - field_defaults + container, + table_defaults )) for field in table.all_fields: + field_name = field.name.lower() + field_defaults = None if table_defaults.get('items'): - field_defaults = table_defaults['items'].get(field.name.lower()) + field_defaults = table_defaults['items'].get(field_name) - item_key = (sp_local_container.name, field_name) - sp_local_container_item = item_map[item_key] + item_key = (container.name, field_name) + item = item_map[item_key] - item_str_batch.extend( + str_batch.extend( create_field_strings( field_name, table_name, - sp_local_container_item, + item, field_defaults, ) ) - # 8. Bulk create itemstrs - Splocaleitemstr.objects.bulk_create(item_str_batch, ignore_conflicts=True) + Splocaleitemstr.objects.bulk_create(str_batch, ignore_conflicts=True) def create_table_container( table_name: str, @@ -248,31 +245,14 @@ def create_field_item( def create_table_strings( table_name: str, container, - item, table_defaults: dict = None, apps = global_apps, ): - Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') - Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') - Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') - table = datamodel.get_table(table_name) table_name_str = table_defaults.get('name', camel_to_spaced_title_case(uncapitilize(table.name))) table_desc_str = table_defaults.get('desc', camel_to_spaced_title_case(uncapitilize(table.name))) - strings = [] - for k, text in { - "containername": table_name_str, - "containerdesc": table_desc_str, - }.items(): - item_str = { - "text": text, - "language": "en", - "version": 0, - } - item_str[k] = container - strings.append(Splocaleitemstr(**item_str)) - return strings + return create_strings('container', container, table_name_str, table_desc_str, apps) def create_field_strings( field_name: str, @@ -281,10 +261,6 @@ def create_field_strings( field_defaults: dict = None, apps = global_apps, ): - Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') - Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') - Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') - table = datamodel.get_table(table_name) field = table.get_field_strict(field_name) field_name = field.name @@ -295,10 +271,20 @@ def create_field_strings( field_name_str = field_defaults.get('name', field_name_str) field_desc_str = field_defaults.get('desc', field_desc_str) + return create_strings('item', item, field_name_str, field_desc_str, apps) + +def create_strings( + parent_type: Literal['item', 'container'], + item, + name_str, + desc_str, + apps = global_apps, +): + Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') strings = [] for k, text in { - "itemname": field_name_str, - "itemdesc": field_desc_str, + f"{parent_type}name": name_str, + f"{parent_type}desc": desc_str, }.items(): itm_str = { 'text': text, @@ -307,10 +293,8 @@ def create_field_strings( } itm_str[k] = item strings.append(Splocaleitemstr(**itm_str)) - return strings - def update_table_schema_config_with_defaults( table_name, discipline_id: int, From db8a3651b63e9c65f360e0646b5c48c5354a85e7 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 09:34:50 -0600 Subject: [PATCH 04/52] Remove root node --- .../backend/setup_tool/tree_defaults.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 4cabae84742..16dd8c04b73 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -32,24 +32,7 @@ def create_default_tree(name: str, kwargs: dict, ranks: dict, preload_tree: Opti rankid=rank_id, parent=previous_tree_def_item, ) - root_tree_def_item, create = tree_rank_model.objects.get_or_create( - treedef=treedef, - rankid=0 - ) - - # Create root node - # TODO: Avoid having duplicated code from add_root endpoint - root_node = tree_node_model.objects.create( - name="Root", - isaccepted=1, - nodenumber=1, - rankid=0, - parent=None, - definition=treedef, - definitionitem=root_tree_def_item, - fullname="Root" - ) - + # TODO: Preload tree if preload_tree is not None: pass From 5f25e95d631255f94710730b2af4239ac198519c Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 13:05:37 -0600 Subject: [PATCH 05/52] Add rank field configuration to forms Fix nested objects in forms --- .../lib/components/SetupTool/SetupForm.tsx | 9 +- .../components/SetupTool/SetupOverview.tsx | 4 +- .../js_src/lib/components/SetupTool/index.tsx | 1 + .../components/SetupTool/setupResources.ts | 130 ++++++++++-------- .../js_src/lib/localization/setupTool.ts | 2 +- 5 files changed, 78 insertions(+), 68 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 8747b774958..d15a45385f4 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -79,11 +79,12 @@ export function renderFormFieldFactory({ options, fields, passwordRepeat, + width, } = field; const fieldName = parentName === undefined ? name : `${parentName}.${name}`; - const colSpan = type === 'object' ? 2 : 1; + const colSpan = width ? `col-span-${width}` : (type === 'object' ? 'col-span-4' : 'col-span-2'); const disciplineTypeValue = resources[currentStep].resourceName === 'discipline' @@ -95,7 +96,7 @@ export function renderFormFieldFactory({ (disciplineTypeValue === undefined || disciplineTypeValue === ''); return ( -
+
{type === 'boolean' ? (
@@ -204,7 +205,7 @@ export function renderFormFieldFactory({

{label}

- {fields ? renderFormFields(fields, name) : null} + {fields ? renderFormFields(fields, fieldName) : null}
) : ( @@ -224,7 +225,7 @@ export function renderFormFieldFactory({ }; const renderFormFields = (fields: RA, parentName?: string) => ( -
+
{fields.map((field) => renderFormField(field, parentName))}
); diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx index 27803308ffb..3c7ca9cf8a2 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx @@ -46,7 +46,7 @@ export function SetupOverview({ if (field.type === 'object') { // Construct a sub list of properties field.fields?.map((child_field) => - fieldDisplay(child_field, field.name) + fieldDisplay(child_field, fieldName) ); return ( {field.fields?.map((child) => ( {fieldDisplay( child, diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index b8495478b84..4bac0efe29d 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -70,6 +70,7 @@ function useFormDefaults( const applyFieldDefaults = (field: FieldConfig, parentName?: string) => { const fieldName = parentName === undefined ? field.name : `${parentName}.${field.name}`; + console.log(fieldName); if (field.type === 'object' && field.fields !== undefined) field.fields.forEach((field) => applyFieldDefaults(field, fieldName)); if (field.default !== undefined) defaultFormData[fieldName] = field.default; diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 819456039fa..b90579d457d 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -10,7 +10,6 @@ export const FIELD_MAX_LENGTH = 64; export type ResourceConfig = { readonly resourceName: string; readonly label: LocalizedString; - readonly endpoint: string; readonly description?: LocalizedString; readonly condition?: Record< string, @@ -40,6 +39,7 @@ export type FieldConfig = { readonly description: string; }; readonly maxLength?: number; + readonly width?: number; }; // Discipline list from backend/context/app_resource.py @@ -72,12 +72,61 @@ const fullNameDirections = [ { value: -1, label: formsText.reverse() }, ]; +function generateTreeRankFields( + rankNames: RA, + enabled: RA, + enforced: RA, + inFullName: RA, + separator: string = ', ' +): RA { + return rankNames.map( + (rankName, index) => { + return { + name: rankName.toLowerCase(), + label: rankName, + type: 'object', + fields: [ + { + name: 'include', + label: 'Include', + type: 'boolean', + default: index === 0 || enabled.includes(rankName), + required: index === 0, + width: 1 + }, + { + name: 'isEnforced', + label: 'Enforced', + type: 'boolean', + default: index === 0 || enforced.includes(rankName), + required: index === 0, + width: 1 + }, + { + name: 'isInFullName', + label: 'In Full Name', + type: 'boolean', + default: inFullName.includes(rankName), + width: 1 + }, + { + name: 'fullNameSeparator', + label: 'Separator', + type: 'text', + default: separator, + width: 1 + } + ] + } as FieldConfig + } + ) +} + export const resources: RA = [ { resourceName: 'institution', label: setupToolText.institution(), description: setupToolText.institutionDescription(), - endpoint: '/setup_tool/institution/create/', documentationUrl: 'https://discourse.specifysoftware.org/t/guided-setup/3234', fields: [ @@ -166,7 +215,6 @@ export const resources: RA = [ { resourceName: 'storageTreeDef', label: setupToolText.storageTree(), - endpoint: '/setup_tool/storagetreedef/create/', fields: [ { name: 'ranks', @@ -174,24 +222,12 @@ export const resources: RA = [ required: false, type: 'object', // TODO: Rank fields should be generated from a .json file. - fields: [ - { - name: '0', - label: 'Site', - type: 'boolean', - default: true, - required: true, - }, - { name: '100', label: 'Building', type: 'boolean', default: true }, - { name: '150', label: 'Collection', type: 'boolean', default: true }, - { name: '200', label: 'Room', type: 'boolean', default: true }, - { name: '250', label: 'Aisle', type: 'boolean', default: true }, - { name: '300', label: 'Cabinet', type: 'boolean', default: true }, - { name: '350', label: 'Shelf', type: 'boolean' }, - { name: '400', label: 'Box', type: 'boolean' }, - { name: '450', label: 'Rack', type: 'boolean' }, - { name: '500', label: 'Vial', type: 'boolean' }, - ], + fields: generateTreeRankFields( + ['Site', 'Building', 'Collection', 'Room', 'Aisle', 'Cabinet', 'Shelf', 'Box', 'Rack', 'Vial'], + ['Site', 'Building', 'Collection', 'Room', 'Aisle', 'Cabinet'], + [], + [] + ) }, // TODO: This should be name direction. Each rank should have configurable formats, too., { @@ -207,7 +243,6 @@ export const resources: RA = [ { resourceName: 'division', label: setupToolText.division(), - endpoint: '/setup_tool/division/create/', fields: [ { name: 'name', label: setupToolText.divisionName(), required: true }, { name: 'abbrev', label: setupToolText.divisionAbbrev(), required: true }, @@ -216,7 +251,6 @@ export const resources: RA = [ { resourceName: 'discipline', label: setupToolText.discipline(), - endpoint: '/setup_tool/discipline/create/', fields: [ { name: 'type', @@ -236,26 +270,18 @@ export const resources: RA = [ { resourceName: 'geographyTreeDef', label: setupToolText.geographyTree(), - endpoint: '/setup_tool/geographytreedef/create/', fields: [ { name: 'ranks', label: setupToolText.treeRanks(), required: false, type: 'object', - fields: [ - { - name: '0', - label: 'Earth', - type: 'boolean', - default: true, - required: true, - }, - { name: '100', label: 'Continent', type: 'boolean', default: true }, - { name: '200', label: 'Country', type: 'boolean', default: true }, - { name: '300', label: 'State', type: 'boolean', default: true }, - { name: '400', label: 'County', type: 'boolean', default: true }, - ], + fields: generateTreeRankFields( + ['Earth', 'Continent', 'Country', 'State', 'County'], + ['Earth', 'Continent', 'Country', 'State', 'County'], + ['Earth', 'Continent', 'Country', 'State', 'County'], + [] + ) }, { name: 'fullNameDirection', @@ -277,34 +303,18 @@ export const resources: RA = [ { resourceName: 'taxonTreeDef', label: setupToolText.taxonTree(), - endpoint: '/setup_tool/taxontreedef/create/', fields: [ { name: 'ranks', label: setupToolText.treeRanks(), required: false, type: 'object', - fields: [ - { - name: '0', - label: 'Life', - type: 'boolean', - default: true, - required: true, - }, - { name: '10', label: 'Kingdom', type: 'boolean', default: true }, - { name: '30', label: 'Phylum', type: 'boolean', default: true }, - { name: '40', label: 'Subphylum', type: 'boolean', default: false }, - { name: '60', label: 'Class', type: 'boolean', default: true }, - { name: '70', label: 'Subclass', type: 'boolean', default: false }, - { name: '90', label: 'Superorder', type: 'boolean', default: false }, - { name: '100', label: 'Order', type: 'boolean', default: true }, - { name: '140', label: 'Family', type: 'boolean', default: true }, - { name: '150', label: 'Subfamily', type: 'boolean', default: false }, - { name: '180', label: 'Genus', type: 'boolean', default: true }, - { name: '220', label: 'Species', type: 'boolean', default: true }, - { name: '230', label: 'Subspecies', type: 'boolean', default: false }, - ], + fields: generateTreeRankFields( + ['Life', 'Kingdom', 'Phylum', 'Subphylum', 'Class', 'Subclass', 'Superorder', 'Order', 'Family', 'Subfamily', 'Genus', 'Species', 'Subspecies'], + ['Life', 'Kingdom', 'Phylum', 'Class', 'Order', 'Family', 'Genus', 'Species'], + ['Life', 'Kingdom', 'Phylum', 'Class', 'Order', 'Family', 'Genus', 'Species'], + ['Genus', 'Species', 'Subspecies'] + ) }, { name: 'fullNameDirection', @@ -327,7 +337,6 @@ export const resources: RA = [ { resourceName: 'collection', label: setupToolText.collection(), - endpoint: '/setup_tool/collection/create/', fields: [ { name: 'collectionName', @@ -354,7 +363,6 @@ export const resources: RA = [ { resourceName: 'specifyUser', label: setupToolText.specifyUser(), - endpoint: '/setup_tool/specifyuser/create/', fields: [ { name: 'firstname', diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index 7fceb81c5ba..cb914abca08 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -215,7 +215,7 @@ export const setupToolText = createDictionary({ }, specifyUserLastNameDescription: { 'en-us': - 'The last name of the agent associated with the account. Optional.', + 'The last name of the agent associated with the account.', }, taxonTreeSetUp: { From 95f37f1c1d9b414b7d798c3554b63b516f48f4ff Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:01:35 -0700 Subject: [PATCH 06/52] Add a new treeDefault import feature Fixes #6294 --- specifyweb/frontend/js_src/lib/localization/tree.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index b5613d643d9..c06d71a3891 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -770,4 +770,10 @@ export const treeText = createDictionary({ 'uk-ua': 'Якщо це ввімкнено, користувачі можуть додавати дочірні елементи до синонімізованих батьківських елементів та синонімізувати вузол з дочірніми елементами.', }, + populatedTrees: { + 'en-us': 'Populated trees', + }, + emptyTrees: { + 'en-us': 'Empty Trees', + }, } as const); From b466c7ceb6d9ed5f7deb21c75c5c7f720e4c390d Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 12 Jun 2025 09:38:33 -0500 Subject: [PATCH 07/52] create_default_trees_task fix --- specifyweb/backend/trees/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/trees/views.py b/specifyweb/backend/trees/views.py index d8be1198b3e..2555438ed9f 100644 --- a/specifyweb/backend/trees/views.py +++ b/specifyweb/backend/trees/views.py @@ -883,4 +883,4 @@ def abort_default_tree_creation(request, task_id: str) -> http.HttpResponse: return http.HttpResponse('', status=204) except Exception as e: - return http.JsonResponse({'error': str(e)}, status=400) \ No newline at end of file + return http.JsonResponse({'error': str(e)}, status=400) From d5ab59866d798aeb049cb433511d0bd43ced9d02 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 13 Nov 2025 13:43:43 -0600 Subject: [PATCH 08/52] Add label to progress bar --- specifyweb/frontend/js_src/lib/localization/tree.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index c06d71a3891..f7246a813f0 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -776,4 +776,9 @@ export const treeText = createDictionary({ emptyTrees: { 'en-us': 'Empty Trees', }, + defaultTreeCreationProgress: { + comment: 'E.x, Creating tree record 999/1,000', + 'en-us': + 'Creating tree record {current:number|formatted}/{total:number|formatted}', + }, } as const); From f3c8ee94f4388ca109b73df195f3e22604f409dd Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 17 Dec 2025 21:20:17 +0000 Subject: [PATCH 09/52] Lint code with ESLint and Prettier Triggered by 7736f9802579637fc9b7876392c6438ce3cd152b on branch refs/heads/issue-6294 --- .../lib/components/Notifications/NotificationRenderers.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 7548864725b..3ab4dd7296d 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -3,11 +3,13 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useBooleanState } from '../../hooks/useBooleanState'; import { backupText } from '../../localization/backup'; +import { commonText } from '../../localization/common'; import { localityText } from '../../localization/locality'; import { mergingText } from '../../localization/merging'; import { notificationsText } from '../../localization/notifications'; import { treeText } from '../../localization/tree'; import { StringToJsx } from '../../localization/utils'; +import { ping } from '../../utils/ajax/ping'; import type { IR, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Link } from '../Atoms/Link'; From 7d1c295634319326b29b6a7fd056ff39b7a6c0e6 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Tue, 13 Jan 2026 12:09:09 -0600 Subject: [PATCH 10/52] fix tests --- .../lib/components/Notifications/NotificationRenderers.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 3ab4dd7296d..7548864725b 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -3,13 +3,11 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useBooleanState } from '../../hooks/useBooleanState'; import { backupText } from '../../localization/backup'; -import { commonText } from '../../localization/common'; import { localityText } from '../../localization/locality'; import { mergingText } from '../../localization/merging'; import { notificationsText } from '../../localization/notifications'; import { treeText } from '../../localization/tree'; import { StringToJsx } from '../../localization/utils'; -import { ping } from '../../utils/ajax/ping'; import type { IR, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Link } from '../Atoms/Link'; From 4e13355fcd7e6f11574fb417511a2e2a43712ae6 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 14:37:33 -0600 Subject: [PATCH 11/52] Update default files Update field names --- config/common/geography_tree.json | 2 +- config/common/storage_tree.json | 2 +- .../js_src/lib/components/SetupTool/setupResources.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/common/geography_tree.json b/config/common/geography_tree.json index 6792f658bb4..a9b6dd84050 100644 --- a/config/common/geography_tree.json +++ b/config/common/geography_tree.json @@ -1,5 +1,5 @@ { - "geography_tree": { + "tree": { "treedef": { "levels": [ { diff --git a/config/common/storage_tree.json b/config/common/storage_tree.json index b6ab8843805..ce89d468c10 100644 --- a/config/common/storage_tree.json +++ b/config/common/storage_tree.json @@ -1,5 +1,5 @@ { - "storage_tree": { + "tree": { "treedef": { "levels": [ { diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index b90579d457d..f513ba67bc5 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -95,7 +95,7 @@ function generateTreeRankFields( width: 1 }, { - name: 'isEnforced', + name: 'enforced', label: 'Enforced', type: 'boolean', default: index === 0 || enforced.includes(rankName), @@ -103,14 +103,14 @@ function generateTreeRankFields( width: 1 }, { - name: 'isInFullName', + name: 'infullname', label: 'In Full Name', type: 'boolean', default: inFullName.includes(rankName), width: 1 }, { - name: 'fullNameSeparator', + name: 'fullnameseparator', label: 'Separator', type: 'text', default: separator, From c5bc74884e594ab97557ff61926bdc3fd6c64b29 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 14:46:04 -0600 Subject: [PATCH 12/52] WIP create trees using user's rank configuration --- specifyweb/backend/setup_tool/setup_tasks.py | 5 +- .../backend/setup_tool/tree_defaults.py | 95 +++++++++++++------ specifyweb/backend/trees/utils.py | 22 +++-- 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py index 9f2af7fdcb5..c4c61b732c9 100644 --- a/specifyweb/backend/setup_tool/setup_tasks.py +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -95,10 +95,7 @@ def update_progress(): discipline_type = data['discipline'].get('type', '') is_paleo_geo = discipline_type in PALEO_DISCIPLINES or discipline_type in GEOLOGY_DISCIPLINES default_tree = { - 'fullnamedirection': 1, - 'ranks': { - '0': True - } + 'ranks': {} } # if is_paleo_geo: diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 16dd8c04b73..9e13c8f13d7 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -1,43 +1,78 @@ from django.db import transaction from django.db.models import Model as DjangoModel -from typing import Type, Optional +from typing import Type, Optional, List +from pathlib import Path -from ..trees.utils import get_models +from .utils import load_json_from_file +from specifyweb.backend.trees.utils import initialize_default_tree import logging logger = logging.getLogger(__name__) -def create_default_tree(name: str, kwargs: dict, ranks: dict, preload_tree: Optional[str]): +DEFAULT_TREE_RANKS_FILES = { + 'Storage': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json', + 'Geography': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'geography_tree.json', + # TODO: Defaults for the rest of the trees + 'Taxon': Path(__file__).parent.parent.parent.parent / 'config' / 'mammal' / 'taxon_mammal_tree.json', + 'Geologictimeperiod': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json', + 'Lithostrat': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json', + 'Tectonicunit': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json' +} + +def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[str]): """Creates an initial empty tree. This should not be used outside of the initial database setup.""" - with transaction.atomic(): - tree_def_model, tree_rank_model, tree_node_model = get_models(name) - - if tree_def_model.objects.count() > 0: - raise RuntimeError(f'Tree {name} already exists, cannot create default.') - - # Create tree definition - treedef = tree_def_model.objects.create( - name=name, - **kwargs, - ) - - # Create tree ranks - previous_tree_def_item = None - rank_list = list(int(rank_id) for rank_id, enabled in ranks.items() if enabled) - rank_list.sort() - for rank_id in rank_list: - previous_tree_def_item = tree_rank_model.objects.create( - treedef=treedef, - name=str(rank_id), # TODO: allow rank name configuration - rankid=rank_id, - parent=previous_tree_def_item, - ) + # Load all default ranks for this type of tree + rank_data = load_json_from_file(DEFAULT_TREE_RANKS_FILES.get(tree_type)) + if rank_data is None: + raise Exception(f'Could not load default rank JSON file for tree type {tree_type}.') + + # list[RankConfiguration] + default_rank_cfg = rank_data.get('tree',{}).get('treedef',{}).get('levels') + if default_rank_cfg is None: + logger.debug(rank_data) + raise Exception(f'No default ranks found in the {tree_type} rank JSON file.') + + # Override default configuration with user's configuration + configurable_fields = {'title', 'enforced', 'infullname', 'fullnameseparator'} + + rank_cfg = [] + for rank in default_rank_cfg: + name = rank.get('name', '').lower() + user_rank = user_rank_cfg.get(name) + + # Initially assume all ranks should be included, except those explicitly set to False + rank_included = not (user_rank == False) - # TODO: Preload tree - if preload_tree is not None: - pass + if isinstance(user_rank, dict): + # The user configured this rank's properties + rank_included = user_rank.get('include', True) + + for field in configurable_fields: + rank[field] = user_rank.get(field, rank[field]) + + if not rank_included: + # The user disabled this rank. + continue + # Add this rank to the final rank configuration + rank_cfg.append(rank) + + if tree_type == 'Storage': + discipline_or_institution = kwargs['institution'] + else: + discipline_or_institution = kwargs['discipline'] + + tree_def = initialize_default_tree(tree_type.lower(), discipline_or_institution, tree_type.title(), rank_cfg, kwargs['fullnamedirection']) + + # TODO: Preload tree + # if preload_tree is not None: + # task_id = str(uuid4()) + # create_default_tree_task.apply_async( + # args=[url, discipline.id, tree_discipline_name, request.specify_collection.id, request.specify_user.id, tree_cfg, row_count, tree_name], + # task_id=f"create_default_tree_{tree_discipline_name}_{task_id}", + # taskid=task_id + # ) - return treedef + return tree_def def update_tree_scoping(treedef: Type[DjangoModel], discipline_id: int): """Trees may be created before a discipline is created. This will update their discipline.""" diff --git a/specifyweb/backend/trees/utils.py b/specifyweb/backend/trees/utils.py index 506fc8610b6..a24f3a08872 100644 --- a/specifyweb/backend/trees/utils.py +++ b/specifyweb/backend/trees/utils.py @@ -115,7 +115,7 @@ class RankConfiguration(TypedDict): fullnameseparator: NotRequired[str] rank: int # rank id -def initialize_default_tree(tree_type: str, discipline, tree_name: str, rank_cfg: list[RankConfiguration], full_name_direction: int=1): +def initialize_default_tree(tree_type: str, discipline_or_institution, tree_name: str, rank_cfg: list[RankConfiguration], full_name_direction: int=1): """Creates an initial empty tree.""" with transaction.atomic(): tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) @@ -130,10 +130,19 @@ def initialize_default_tree(tree_type: str, discipline, tree_name: str, rank_cfg unique_tree_name = f"{tree_name}_{i}" # Create tree definition + if tree_type == 'storage': + scope = { + 'institution': discipline_or_institution + } + else: + scope = { + 'discipline': discipline_or_institution + } + tree_def, _ = tree_def_model.objects.get_or_create( name=unique_tree_name, - discipline=discipline, - fullnamedirection=full_name_direction + fullnamedirection=full_name_direction, + **scope ) # Create tree ranks @@ -167,7 +176,7 @@ def initialize_default_tree(tree_type: str, discipline, tree_name: str, rank_cfg parent=None ) - return tree_def.name + return tree_def class RankMappingConfiguration(TypedDict): name: str @@ -286,7 +295,7 @@ def progress(cur: int, additional_total: int=0) -> None: # Create a new empty tree. Get rank configuration from the mapping. full_name_direction = 1 - if tree_type in ('geologictimeperiod'): + if tree_type in ('geologictimeperiod',): full_name_direction = -1 rank_cfg = [{ @@ -305,7 +314,8 @@ def progress(cur: int, additional_total: int=0) -> None: 'rank': rank.get('rank', auto_rank_id) }) auto_rank_id += 10 - tree_name = initialize_default_tree(tree_type, discipline, initial_tree_name, rank_cfg, full_name_direction) + tree_def = initialize_default_tree(tree_type, discipline, initial_tree_name, rank_cfg, full_name_direction) + tree_name = tree_def.name # Start importing CSV data total_rows = 0 From 55ff4c69a192730517b0fa3033834d7a3e749023 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 14:55:44 -0600 Subject: [PATCH 13/52] Fix storage tree default file --- config/common/storage_tree.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/common/storage_tree.json b/config/common/storage_tree.json index ce89d468c10..3c0de68b307 100644 --- a/config/common/storage_tree.json +++ b/config/common/storage_tree.json @@ -53,7 +53,7 @@ { "name": "Rack", "enforced": false, - "infullname": "Rack", + "infullname": false, "rank": 450 }, { From 5e16af4b2481b5ec66eec29aabf8472427cb6050 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 14:59:05 -0600 Subject: [PATCH 14/52] WIP Add default tree files for remaining trees --- config/common/geologictimeperiod_tree.json | 37 ++++++++++ config/common/lithostrat_tree.json | 69 +++++++++++++++++++ config/common/tectonicunit_tree.json | 69 +++++++++++++++++++ .../backend/setup_tool/tree_defaults.py | 6 +- 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 config/common/geologictimeperiod_tree.json create mode 100644 config/common/lithostrat_tree.json create mode 100644 config/common/tectonicunit_tree.json diff --git a/config/common/geologictimeperiod_tree.json b/config/common/geologictimeperiod_tree.json new file mode 100644 index 00000000000..31a3aa4e388 --- /dev/null +++ b/config/common/geologictimeperiod_tree.json @@ -0,0 +1,37 @@ +{ + "tree": { + "treedef": { + "levels": [ + { + "name": "Erathem/Era", + "rank": 100, + "enforced": false, + "infullname": false, + "fullnameseparator": ", " + }, + { + "name": "System/Period", + "rank": 200, + "enforced": false, + "infullname": false, + "fullnameseparator": ", " + }, + { + "name": "Series/Epoch", + "rank": 300, + "enforced": false, + "infullname": true, + "fullnameseparator": ", " + }, + { + "name": "Stage/Age", + "rank": 400, + "enforced": false, + "infullname": true, + "fullnameseparator": ", " + } + ] + }, + "nodes": [] + } +} \ No newline at end of file diff --git a/config/common/lithostrat_tree.json b/config/common/lithostrat_tree.json new file mode 100644 index 00000000000..3c0de68b307 --- /dev/null +++ b/config/common/lithostrat_tree.json @@ -0,0 +1,69 @@ +{ + "tree": { + "treedef": { + "levels": [ + { + "name": "Site", + "enforced": true, + "infullname": false, + "rank": 0 + }, + { + "name": "Building", + "enforced": false, + "infullname": false, + "rank": 100 + }, + { + "name": "Collection", + "enforced": false, + "infullname": false, + "rank": 150 + }, + { + "name": "Room", + "enforced": false, + "infullname": false, + "rank": 200 + }, + { + "name": "Aisle", + "enforced": false, + "infullname": false, + "rank": 250 + }, + { + "name": "Cabinet", + "enforced": false, + "infullname": false, + "rank": 300 + }, + { + "name": "Shelf", + "enforced": false, + "infullname": false, + "rank": 350 + }, + { + "name": "Box", + "enforced": false, + "infullname": false, + "rank": 400 + }, + { + "name": "Rack", + "enforced": false, + "infullname": false, + "rank": 450 + }, + { + "name": "Vial", + "enforced": false, + "infullname": false, + "rank": 500 + } + ] + }, + "nodes": [] + } +} \ No newline at end of file diff --git a/config/common/tectonicunit_tree.json b/config/common/tectonicunit_tree.json new file mode 100644 index 00000000000..3c0de68b307 --- /dev/null +++ b/config/common/tectonicunit_tree.json @@ -0,0 +1,69 @@ +{ + "tree": { + "treedef": { + "levels": [ + { + "name": "Site", + "enforced": true, + "infullname": false, + "rank": 0 + }, + { + "name": "Building", + "enforced": false, + "infullname": false, + "rank": 100 + }, + { + "name": "Collection", + "enforced": false, + "infullname": false, + "rank": 150 + }, + { + "name": "Room", + "enforced": false, + "infullname": false, + "rank": 200 + }, + { + "name": "Aisle", + "enforced": false, + "infullname": false, + "rank": 250 + }, + { + "name": "Cabinet", + "enforced": false, + "infullname": false, + "rank": 300 + }, + { + "name": "Shelf", + "enforced": false, + "infullname": false, + "rank": 350 + }, + { + "name": "Box", + "enforced": false, + "infullname": false, + "rank": 400 + }, + { + "name": "Rack", + "enforced": false, + "infullname": false, + "rank": 450 + }, + { + "name": "Vial", + "enforced": false, + "infullname": false, + "rank": 500 + } + ] + }, + "nodes": [] + } +} \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 9e13c8f13d7..71ff6d33a58 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -14,9 +14,9 @@ 'Geography': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'geography_tree.json', # TODO: Defaults for the rest of the trees 'Taxon': Path(__file__).parent.parent.parent.parent / 'config' / 'mammal' / 'taxon_mammal_tree.json', - 'Geologictimeperiod': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json', - 'Lithostrat': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json', - 'Tectonicunit': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json' + 'Geologictimeperiod': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'geologictimeperiod_tree.json', + 'Lithostrat': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'lithostrat_tree.json', + 'Tectonicunit': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'tectonicunit_tree.json' } def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[str]): From 0e3e27aee2f95ad51da369efdb0faa5ceb8bcdc3 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 15:28:37 -0600 Subject: [PATCH 15/52] Fix default files --- config/common/geologictimeperiod_tree.json | 47 +++++++++-------- config/common/lithostrat_tree.json | 36 +++---------- config/common/tectonicunit_tree.json | 50 +++++-------------- .../backend/setup_tool/tree_defaults.py | 5 +- 4 files changed, 48 insertions(+), 90 deletions(-) diff --git a/config/common/geologictimeperiod_tree.json b/config/common/geologictimeperiod_tree.json index 31a3aa4e388..16e952d1f46 100644 --- a/config/common/geologictimeperiod_tree.json +++ b/config/common/geologictimeperiod_tree.json @@ -3,32 +3,39 @@ "treedef": { "levels": [ { - "name": "Erathem/Era", - "rank": 100, - "enforced": false, - "infullname": false, - "fullnameseparator": ", " + "name": "Time Root", + "rank": 0, + "enforced": true, + "infullname": false, + "fullnameseparator": ", " }, { - "name": "System/Period", - "rank": 200, - "enforced": false, - "infullname": false, - "fullnameseparator": ", " + "name": "Erathem/Era", + "rank": 100, + "enforced": false, + "infullname": false, + "fullnameseparator": ", " }, { - "name": "Series/Epoch", - "rank": 300, - "enforced": false, - "infullname": true, - "fullnameseparator": ", " + "name": "System/Period", + "rank": 200, + "enforced": false, + "infullname": false, + "fullnameseparator": ", " }, { - "name": "Stage/Age", - "rank": 400, - "enforced": false, - "infullname": true, - "fullnameseparator": ", " + "name": "Series/Epoch", + "rank": 300, + "enforced": false, + "infullname": true, + "fullnameseparator": ", " + }, + { + "name": "Stage/Age", + "rank": 400, + "enforced": false, + "infullname": true, + "fullnameseparator": ", " } ] }, diff --git a/config/common/lithostrat_tree.json b/config/common/lithostrat_tree.json index 3c0de68b307..9f006679ca8 100644 --- a/config/common/lithostrat_tree.json +++ b/config/common/lithostrat_tree.json @@ -3,61 +3,37 @@ "treedef": { "levels": [ { - "name": "Site", + "name": "Surface", "enforced": true, "infullname": false, "rank": 0 }, { - "name": "Building", + "name": "Super Group", "enforced": false, "infullname": false, "rank": 100 }, { - "name": "Collection", - "enforced": false, - "infullname": false, - "rank": 150 - }, - { - "name": "Room", + "name": "Litho Group", "enforced": false, "infullname": false, "rank": 200 }, { - "name": "Aisle", - "enforced": false, - "infullname": false, - "rank": 250 - }, - { - "name": "Cabinet", + "name": "Formation", "enforced": false, "infullname": false, "rank": 300 }, { - "name": "Shelf", - "enforced": false, - "infullname": false, - "rank": 350 - }, - { - "name": "Box", + "name": "Member", "enforced": false, "infullname": false, "rank": 400 }, { - "name": "Rack", - "enforced": false, - "infullname": false, - "rank": 450 - }, - { - "name": "Vial", + "name": "Bed", "enforced": false, "infullname": false, "rank": 500 diff --git a/config/common/tectonicunit_tree.json b/config/common/tectonicunit_tree.json index 3c0de68b307..ad2739b2bb7 100644 --- a/config/common/tectonicunit_tree.json +++ b/config/common/tectonicunit_tree.json @@ -3,64 +3,40 @@ "treedef": { "levels": [ { - "name": "Site", + "name": "Root", "enforced": true, "infullname": false, "rank": 0 }, { - "name": "Building", + "name": "Superstructure", "enforced": false, "infullname": false, - "rank": 100 + "rank": 10 }, { - "name": "Collection", + "name": "Tectonic Domain", "enforced": false, "infullname": false, - "rank": 150 + "rank": 20 }, { - "name": "Room", + "name": "Tectonic Subdomain", "enforced": false, "infullname": false, - "rank": 200 + "rank": 30 }, { - "name": "Aisle", + "name": "Tectonic Unit", "enforced": false, - "infullname": false, - "rank": 250 - }, - { - "name": "Cabinet", - "enforced": false, - "infullname": false, - "rank": 300 - }, - { - "name": "Shelf", - "enforced": false, - "infullname": false, - "rank": 350 + "infullname": true, + "rank": 40 }, { - "name": "Box", + "name": "Tectonic Subunit", "enforced": false, - "infullname": false, - "rank": 400 - }, - { - "name": "Rack", - "enforced": false, - "infullname": false, - "rank": 450 - }, - { - "name": "Vial", - "enforced": false, - "infullname": false, - "rank": 500 + "infullname": true, + "rank": 50 } ] }, diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 71ff6d33a58..2e16e871349 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -12,7 +12,6 @@ DEFAULT_TREE_RANKS_FILES = { 'Storage': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'storage_tree.json', 'Geography': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'geography_tree.json', - # TODO: Defaults for the rest of the trees 'Taxon': Path(__file__).parent.parent.parent.parent / 'config' / 'mammal' / 'taxon_mammal_tree.json', 'Geologictimeperiod': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'geologictimeperiod_tree.json', 'Lithostrat': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'lithostrat_tree.json', @@ -57,9 +56,9 @@ def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, prelo rank_cfg.append(rank) if tree_type == 'Storage': - discipline_or_institution = kwargs['institution'] + discipline_or_institution = kwargs.get('institution') else: - discipline_or_institution = kwargs['discipline'] + discipline_or_institution = kwargs.get('discipline') tree_def = initialize_default_tree(tree_type.lower(), discipline_or_institution, tree_type.title(), rank_cfg, kwargs['fullnamedirection']) From c2fd7698f8faaec23890e4d996000797b4ed6e01 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 15:55:47 -0600 Subject: [PATCH 16/52] Fix applying user rank configuration --- specifyweb/backend/setup_tool/tree_defaults.py | 6 +++--- .../js_src/lib/components/SetupTool/utils.ts | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 2e16e871349..f212089d127 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -44,16 +44,16 @@ def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, prelo if isinstance(user_rank, dict): # The user configured this rank's properties - rank_included = user_rank.get('include', True) + rank_included = user_rank.get('include', False) for field in configurable_fields: - rank[field] = user_rank.get(field, rank[field]) + rank[field] = user_rank.get(field, rank.get(field)) if not rank_included: # The user disabled this rank. continue # Add this rank to the final rank configuration - rank_cfg.append(rank) + rank_cfg.append(rank) if tree_type == 'Storage': discipline_or_institution = kwargs.get('institution') diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts index f46de738669..d0d8140b620 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts @@ -1,11 +1,23 @@ // Turn 'table.field' keys to nested objects to send to the backend +function setNested(obj: Record, path: string, value: any): void { + const idx = path.indexOf('.'); + if (idx === -1) { + obj[path] = value; + return; + } + const head = path.slice(0, idx); + const rest = path.slice(idx + 1); + if (obj[head] === undefined || typeof obj[head] !== 'object' || Array.isArray(obj[head])) { + obj[head] = {}; + } + setNested(obj[head], rest, value); +} + function flattenToNested(data: Record): Record { const result: Record = {}; Object.entries(data).forEach(([key, value]) => { if (key.includes('.')) { - const [prefix, field] = key.split('.', 2); - result[prefix] ||= {}; - result[prefix][field] = value; + setNested(result, key, value); } else { result[key] = value; } From 9c4a4fd64193a6dc1a77d625d3be057387dbe77a Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:36:40 -0600 Subject: [PATCH 17/52] fix(trees): increase tectonicunit rankids --- config/common/tectonicunit_tree.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/common/tectonicunit_tree.json b/config/common/tectonicunit_tree.json index ad2739b2bb7..1d0537607db 100644 --- a/config/common/tectonicunit_tree.json +++ b/config/common/tectonicunit_tree.json @@ -12,31 +12,31 @@ "name": "Superstructure", "enforced": false, "infullname": false, - "rank": 10 + "rank": 100 }, { "name": "Tectonic Domain", "enforced": false, "infullname": false, - "rank": 20 + "rank": 200 }, { "name": "Tectonic Subdomain", "enforced": false, "infullname": false, - "rank": 30 + "rank": 300 }, { "name": "Tectonic Unit", "enforced": false, "infullname": true, - "rank": 40 + "rank": 400 }, { "name": "Tectonic Subunit", "enforced": false, "infullname": true, - "rank": 50 + "rank": 500 } ] }, From f642a00ea1484b5642eb59211114fbbc33757f76 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:39:13 -0600 Subject: [PATCH 18/52] show province/state --- specifyweb/frontend/js_src/lib/localization/setupTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index cb914abca08..b8589b7f538 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -95,7 +95,7 @@ export const setupToolText = createDictionary({ 'en-us': 'The city where the institution is located.', }, addressState: { - 'en-us': 'State', + 'en-us': 'Province/State', }, addressStateDescription: { 'en-us': 'The state or province.', From 3571e006e01fa26397d31a361966c5c9893d0496 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 16:15:52 -0600 Subject: [PATCH 19/52] WIP allow pre-loading trees --- specifyweb/backend/setup_tool/api.py | 3 +- .../backend/setup_tool/tree_defaults.py | 35 ++++++++++--- specifyweb/backend/trees/utils.py | 51 ++++++++++--------- .../components/SetupTool/setupResources.ts | 35 ++++--------- .../js_src/lib/localization/setupTool.ts | 8 ++- 5 files changed, 68 insertions(+), 64 deletions(-) diff --git a/specifyweb/backend/setup_tool/api.py b/specifyweb/backend/setup_tool/api.py index 359d3b98eba..00930b0b695 100644 --- a/specifyweb/backend/setup_tool/api.py +++ b/specifyweb/backend/setup_tool/api.py @@ -393,8 +393,7 @@ def create_tree(name: str, data: dict) -> dict: ranks = data.pop('ranks', dict()) # Pre-load Default Tree - # TODO: trees/create_default_trees - preload_tree = data.pop('default', None) + preload_tree = data.pop('preload', None) try: kwargs = {} diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index f212089d127..8193082d3fd 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -2,6 +2,9 @@ from django.db.models import Model as DjangoModel from typing import Type, Optional, List from pathlib import Path +from uuid import uuid4 +from specifyweb.backend.trees.utils import create_default_tree_task +import requests from .utils import load_json_from_file from specifyweb.backend.trees.utils import initialize_default_tree @@ -17,9 +20,18 @@ 'Lithostrat': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'lithostrat_tree.json', 'Tectonicunit': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'tectonicunit_tree.json' } +DEFAULT_TREE_URLS = { + 'Geography': 'https://files.specifysoftware.org/geographyfiles/geonames.csv', + 'Geologictimeperiod': 'https://files.specifysoftware.org/treerows/geography.json', +} +DEFAULT_TREE_MAPPING_URLS = { + 'Geography': 'https://files.specifysoftware.org/treerows/geography.json', + 'Geologictimeperiod': 'https://files.specifysoftware.org/treerows/geologictimeperiod.json', +} def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[str]): """Creates an initial empty tree. This should not be used outside of the initial database setup.""" + from specifyweb.specify.models import Collection # Load all default ranks for this type of tree rank_data = load_json_from_file(DEFAULT_TREE_RANKS_FILES.get(tree_type)) if rank_data is None: @@ -62,14 +74,21 @@ def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, prelo tree_def = initialize_default_tree(tree_type.lower(), discipline_or_institution, tree_type.title(), rank_cfg, kwargs['fullnamedirection']) - # TODO: Preload tree - # if preload_tree is not None: - # task_id = str(uuid4()) - # create_default_tree_task.apply_async( - # args=[url, discipline.id, tree_discipline_name, request.specify_collection.id, request.specify_user.id, tree_cfg, row_count, tree_name], - # task_id=f"create_default_tree_{tree_discipline_name}_{task_id}", - # taskid=task_id - # ) + if preload_tree is not None: + collection = Collection.objects.last() + + url = DEFAULT_TREE_URLS.get(tree_type) + mapping_url = DEFAULT_TREE_MAPPING_URLS.get(tree_type) + resp = requests.get(mapping_url) + resp.raise_for_status() + tree_cfg = resp.json() + + task_id = str(uuid4()) + create_default_tree_task.apply_async( + args=[url, discipline_or_institution.id, tree_type.lower(), collection, False, tree_cfg, 0, tree_type.title()], + task_id=f"create_default_tree_{tree_type}_{task_id}", + taskid=task_id + ) return tree_def diff --git a/specifyweb/backend/trees/utils.py b/specifyweb/backend/trees/utils.py index a24f3a08872..0dba30d985f 100644 --- a/specifyweb/backend/trees/utils.py +++ b/specifyweb/backend/trees/utils.py @@ -259,22 +259,23 @@ def add_default_tree_record(tree_type: str, row: dict, tree_name: str, tree_cfg: @app.task(base=LogErrorsTask, bind=True) def create_default_tree_task(self, url: str, discipline_id: int, tree_discipline_name: str, specify_collection_id: int, - specify_user_id: int, tree_cfg: dict, row_count: Optional[int], initial_tree_name: str): + specify_user_id: Optional[int], tree_cfg: dict, row_count: Optional[int], initial_tree_name: str): logger.info(f'starting task {str(self.request.id)}') - specify_user = spmodels.Specifyuser.objects.get(id=specify_user_id) discipline = spmodels.Discipline.objects.get(id=discipline_id) tree_name = initial_tree_name # Name will be uniquified on tree creation - Message.objects.create( - user=specify_user, - content=json.dumps({ - 'type': 'create-default-tree-starting', - 'name': initial_tree_name, - 'taskid': str(self.request.id), - 'collection_id': specify_collection_id, - }) - ) + if specify_user_id: + specify_user = spmodels.Specifyuser.objects.get(id=specify_user_id) + Message.objects.create( + user=specify_user, + content=json.dumps({ + 'type': 'create-default-tree-starting', + 'name': initial_tree_name, + 'taskid': str(self.request.id), + 'collection_id': specify_collection_id, + }) + ) current = 0 total = 1 @@ -326,27 +327,29 @@ def progress(cur: int, additional_total: int=0) -> None: add_default_tree_record(tree_type, row, tree_name, tree_cfg) progress(1, 0) except Exception as e: + if specify_user_id: + Message.objects.create( + user=specify_user, + content=json.dumps({ + 'type': 'create-default-tree-failed', + 'name': tree_name, + 'taskid': str(self.request.id), + 'collection_id': specify_collection_id, + # 'error': str(e) + }) + ) + raise + + if specify_user_id: Message.objects.create( user=specify_user, content=json.dumps({ - 'type': 'create-default-tree-failed', + 'type': 'create-default-tree-completed', 'name': tree_name, 'taskid': str(self.request.id), 'collection_id': specify_collection_id, - # 'error': str(e) }) ) - raise - - Message.objects.create( - user=specify_user, - content=json.dumps({ - 'type': 'create-default-tree-completed', - 'name': tree_name, - 'taskid': str(self.request.id), - 'collection_id': specify_collection_id, - }) - ) def stream_csv_from_url(url: str) -> Iterator[Dict[str, str]]: """ diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index f513ba67bc5..cf17ab0e748 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -200,16 +200,6 @@ export const resources: RA = [ description: setupToolText.institutionIsAccessionGlobalDescription(), type: 'boolean', }, - /* - * { - * name: 'isSingleGeographyTree', - * label: setupToolText_institutionIsSingleGeographyTree(), // underscore in comment to avoid failing test - * description: - * setupToolText_institutionIsSingleGeographyTreeDescription(), - * type: 'boolean', - * default: false, - * }, - */ ], }, { @@ -291,13 +281,11 @@ export const resources: RA = [ required: true, default: fullNameDirections[0].value.toString(), }, - /* - * { - * name: 'default', - * label: setupToolText_defaultTree(), // underscore in comment to avoid failing test - * type: 'boolean', - * }, - */ + { + name: 'preload', + label: setupToolText.preloadTree(), + type: 'boolean', + }, ], }, { @@ -324,14 +312,11 @@ export const resources: RA = [ required: true, default: fullNameDirections[0].value.toString(), }, - /* - * TODO: Select which Taxon tree to import (Re-use dialog from default tree creation in tree viewer) - * { - * name: 'default', - * label: setupToolText_defaultTree(), // underscore in comment to avoid failing test - * type: 'boolean', - * }, - */ + { + name: 'preload', + label: setupToolText.preloadTree(), + type: 'boolean', + }, ], }, { diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index b8589b7f538..0fdd17d452e 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -139,11 +139,9 @@ export const setupToolText = createDictionary({ taxonTree: { 'en-us': 'Taxon Tree', }, - /* - * DefaultTree: { - * 'en-us': 'Pre-load Default Tree' - * }, - */ + preloadTree: { + 'en-us': 'Pre-load Tree' + }, // Division division: { From 9992dac55443a943d55be6d4cf848975b2e462cc Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 15 Jan 2026 08:05:21 -0600 Subject: [PATCH 20/52] Create Geography tree on startup Refactor default tree importing --- specifyweb/backend/setup_tool/api.py | 8 +- specifyweb/backend/setup_tool/setup_tasks.py | 26 +- .../backend/setup_tool/tree_defaults.py | 40 +- specifyweb/backend/trees/defaults.py | 354 ++++++++++++++++++ specifyweb/backend/trees/utils.py | 309 --------------- specifyweb/backend/trees/views.py | 3 +- 6 files changed, 400 insertions(+), 340 deletions(-) create mode 100644 specifyweb/backend/trees/defaults.py diff --git a/specifyweb/backend/setup_tool/api.py b/specifyweb/backend/setup_tool/api.py index 00930b0b695..4ce8d76d708 100644 --- a/specifyweb/backend/setup_tool/api.py +++ b/specifyweb/backend/setup_tool/api.py @@ -215,14 +215,13 @@ def create_discipline(data): # Assign a taxon tree. Not required, but its eventually needed for collection object type. taxontreedef_url = data.get('taxontreedef', None) taxontreedef = resolve_uri_or_fallback(taxontreedef_url, None, Taxontreedef) - if taxontreedef is not None: + if taxontreedef_url and taxontreedef is not None: data['taxontreedef_id'] = taxontreedef.id data.update({ 'datatype_id': datatype.id, 'geographytreedef_id': geographytreedef.id, - 'geologictimeperiodtreedef_id': geologictimeperiodtreedef.id, - 'taxontreedef_id': taxontreedef.id if taxontreedef else None + 'geologictimeperiodtreedef_id': geologictimeperiodtreedef.id }) # Assign new Discipline ID @@ -365,9 +364,6 @@ def create_tectonicunit_tree(data): return create_tree('Tectonicunit', data) def create_tree(name: str, data: dict) -> dict: - # TODO: Use trees/create_default_trees - # https://github.com/specify/specify7/pull/6429 - # Figure out which scoping field should be used. use_institution = False use_discipline = True diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py index c4c61b732c9..871ba958306 100644 --- a/specifyweb/backend/setup_tool/setup_tasks.py +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -8,6 +8,7 @@ from celery.result import AsyncResult from specifyweb.backend.setup_tool import api from specifyweb.backend.setup_tool.app_resource_defaults import create_app_resource_defaults +from specifyweb.backend.setup_tool.tree_defaults import preload_default_tree from specifyweb.specify.management.commands.run_key_migration_functions import fix_schema_config from specifyweb.specify.models_utils.model_extras import PALEO_DISCIPLINES, GEOLOGY_DISCIPLINES from specifyweb.celery_tasks import is_worker_alive, MissingWorkerError @@ -103,11 +104,13 @@ def update_progress(): logger.debug('Creating Chronostratigraphy tree') default_chronostrat_tree = default_tree.copy() default_chronostrat_tree['fullnamedirection'] = -1 - api.create_geologictimeperiod_tree(default_chronostrat_tree) + chronostrat_result = api.create_geologictimeperiod_tree(default_chronostrat_tree) + chronostrat_treedef_id = chronostrat_result.get('treedef_id') logger.debug('Creating geography tree') uses_global_geography_tree = data['institution'].get('issinglegeographytree', False) - api.create_geography_tree(data['geographytreedef'], global_tree=uses_global_geography_tree) + geography_result = api.create_geography_tree(data['geographytreedef'].copy(), global_tree=uses_global_geography_tree) + geography_treedef_id = geography_result.get('treedef_id') logger.debug('Creating discipline') discipline_result = api.create_discipline(data['discipline']) @@ -125,19 +128,32 @@ def update_progress(): logger.debug('Creating taxon tree') if data['taxontreedef'].get('discipline_id') is None: data['taxontreedef']['discipline_id'] = discipline_id - api.create_taxon_tree(data['taxontreedef']) + taxon_result = api.create_taxon_tree(data['taxontreedef'].copy()) + taxon_treedef_id = taxon_result.get('treedef_id') update_progress() logger.debug('Creating collection') - api.create_collection(data['collection']) + collection_result = api.create_collection(data['collection']) + collection_id = collection_result.get('collection_id') update_progress() logger.debug('Creating specify user') - api.create_specifyuser(data['specifyuser']) + specifyuser_result = api.create_specifyuser(data['specifyuser']) + specifyuser_id = specifyuser_result.get('user_id') logger.debug('Finalizing database') fix_schema_config() create_app_resource_defaults() + if is_paleo_geo: + preload_default_tree('Geologictimeperiod', discipline_id, collection_id, chronostrat_treedef_id, specifyuser_id) + logger.debug(data['geographytreedef']) + if data['geographytreedef'].get('preload'): + logger.debug("trying to create geography tree") + preload_default_tree('Geography', discipline_id, collection_id, geography_treedef_id, specifyuser_id) + if data['taxontreedef'].get('preload'): + logger.debug('trying to create taxon tree') + preload_default_tree('Taxon', discipline_id, collection_id, taxon_treedef_id, specifyuser_id) + update_progress() except Exception as e: logger.exception(f'Error setting up database: {e}') diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 8193082d3fd..096b917258f 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -1,13 +1,13 @@ from django.db import transaction from django.db.models import Model as DjangoModel +from specifyweb.specify.models import Discipline, Collection from typing import Type, Optional, List from pathlib import Path from uuid import uuid4 -from specifyweb.backend.trees.utils import create_default_tree_task import requests from .utils import load_json_from_file -from specifyweb.backend.trees.utils import initialize_default_tree +from specifyweb.backend.trees.defaults import initialize_default_tree, create_default_tree_task import logging logger = logging.getLogger(__name__) @@ -29,9 +29,8 @@ 'Geologictimeperiod': 'https://files.specifysoftware.org/treerows/geologictimeperiod.json', } -def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[str]): +def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[bool]): """Creates an initial empty tree. This should not be used outside of the initial database setup.""" - from specifyweb.specify.models import Collection # Load all default ranks for this type of tree rank_data = load_json_from_file(DEFAULT_TREE_RANKS_FILES.get(tree_type)) if rank_data is None: @@ -74,23 +73,26 @@ def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, prelo tree_def = initialize_default_tree(tree_type.lower(), discipline_or_institution, tree_type.title(), rank_cfg, kwargs['fullnamedirection']) - if preload_tree is not None: - collection = Collection.objects.last() - - url = DEFAULT_TREE_URLS.get(tree_type) - mapping_url = DEFAULT_TREE_MAPPING_URLS.get(tree_type) - resp = requests.get(mapping_url) - resp.raise_for_status() - tree_cfg = resp.json() + return tree_def - task_id = str(uuid4()) - create_default_tree_task.apply_async( - args=[url, discipline_or_institution.id, tree_type.lower(), collection, False, tree_cfg, 0, tree_type.title()], - task_id=f"create_default_tree_{tree_type}_{task_id}", - taskid=task_id - ) +def preload_default_tree(tree_type: str, discipline_id: Optional[int], collection_id: Optional[int], tree_def_id: int, specify_user_id: Optional[int]): + """Automatically creates a populated default tree.""" + # Tree download config: + tree_discipline_name = tree_type.lower() + tree_name = tree_type.title() + # Tree file urls + url = DEFAULT_TREE_URLS.get(tree_type) + mapping_url = DEFAULT_TREE_MAPPING_URLS.get(tree_type) + resp = requests.get(mapping_url) + resp.raise_for_status() + tree_cfg = resp.json() - return tree_def + task_id = str(uuid4()) + create_default_tree_task.apply_async( + args=[url, discipline_id, tree_discipline_name, collection_id, specify_user_id, tree_cfg, 1000000, tree_name, tree_def_id], + task_id=f"create_default_tree_{tree_type}_{task_id}", + taskid=task_id + ) def update_tree_scoping(treedef: Type[DjangoModel], discipline_id: int): """Trees may be created before a discipline is created. This will update their discipline.""" diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py new file mode 100644 index 00000000000..081b953af0f --- /dev/null +++ b/specifyweb/backend/trees/defaults.py @@ -0,0 +1,354 @@ +from typing import Any, Callable, List, Dict, Iterator, Optional, TypedDict, NotRequired +import json +import requests +import csv +import time +from requests.exceptions import ChunkedEncodingError, ConnectionError + +from django.db import transaction + +from specifyweb.backend.notifications.models import Message +from specifyweb.celery_tasks import LogErrorsTask, app +import specifyweb.specify.models as spmodels +from specifyweb.backend.trees.utils import get_models, SPECIFY_TREES, TREE_ROOT_NODES + +import logging +logger = logging.getLogger(__name__) + +class RankConfiguration(TypedDict): + name: str + title: NotRequired[str] + enforced: bool + infullname: bool + fullnameseparator: NotRequired[str] + rank: int # rank id + +def initialize_default_tree(tree_type: str, discipline_or_institution, tree_name: str, rank_cfg: list[RankConfiguration], full_name_direction: int=1): + """Creates an initial empty tree.""" + with transaction.atomic(): + tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) + + # Uniquify name + tree_def = None + unique_tree_name = tree_name + if tree_def_model.objects.filter(name=tree_name).exists(): + i = 1 + while tree_def_model.objects.filter(name=f"{tree_name}_{i}").exists(): + i += 1 + unique_tree_name = f"{tree_name}_{i}" + + # Create tree definition + scope = {} + if discipline_or_institution: + if tree_type == 'storage': + scope = { + 'institution': discipline_or_institution + } + else: + scope = { + 'discipline': discipline_or_institution + } + + tree_def, _ = tree_def_model.objects.get_or_create( + name=unique_tree_name, + fullnamedirection=full_name_direction, + **scope + ) + + # Create tree ranks + treedefitems_bulk = [] + rank_id = 0 + for rank in rank_cfg: + treedefitems_bulk.append( + tree_rank_model( + treedef=tree_def, + name=rank.get('name'), + title=rank.get('title') or rank.get('name').title(), + rankid=int(rank.get('rank', rank_id)), + isenforced=rank.get('enforced', True), + isinfullname=rank.get('infullname', False), + fullnameseparator=rank.get('fullnameseparator', ' ') + ) + ) + rank_id += 10 + if treedefitems_bulk: + tree_rank_model.objects.bulk_create(treedefitems_bulk, ignore_conflicts=False) + + # Create root node + # TODO: Avoid having duplicated code from add_root endpoint + root_rank = tree_rank_model.objects.get(treedef=tree_def, rankid=0) + tree_node, _ = tree_node_model.objects.get_or_create( + name=TREE_ROOT_NODES.get(tree_type, "Root"), + fullname=TREE_ROOT_NODES.get(tree_type, "Root"), + nodenumber=1, + definition=tree_def, + definitionitem=root_rank, + parent=None + ) + + return tree_def + +class RankMappingConfiguration(TypedDict): + name: str + column: str + enforced: NotRequired[bool] + rank: NotRequired[int] + infullname: NotRequired[bool] + fullnameseparator: NotRequired[str] + fields: Dict[str, str] + +class DefaultTreeContext(): + """Context for a default tree creation task""" + def __init__(self, tree_type: str, tree_name: str): + self.tree_type = tree_type + self.tree_name = tree_name + + self.tree_def_model, self.tree_rank_model, self.tree_node_model = get_models(tree_type) + + self.tree_def = self.tree_def_model.objects.get(name=tree_name) + self.tree_def_item_map = self.create_rank_map() + self.root_parent = self.tree_node_model.objects.filter( + definitionitem__rankid=0, + definition=self.tree_def + ).first() + + def create_rank_map(self): + """Rank lookup map to reduce queries""" + return { + rank.name: rank + for rank in self.tree_rank_model.objects.filter(treedef=self.tree_def) + } + +def add_default_tree_record(context: DefaultTreeContext, row: dict, tree_cfg: dict[str, RankMappingConfiguration]): + """ + Given one CSV row and a column mapping / rank configuration dictionary, + walk through the 'ranks' in order, creating or updating each tree record and linking + it to its parent. + """ + tree_node_model = context.tree_node_model + tree_def = context.tree_def + parent = context.root_parent + rank_id = 10 + + for rank_mapping in tree_cfg['ranks']: + rank_name = rank_mapping['name'] + fields_mapping = rank_mapping['fields'] + + record_name = row.get(rank_mapping.get('column', rank_name)) # Record's name is in the column. + + if not record_name: + continue # This row doesn't contain a record for this rank. + + defaults = {} + for model_field, csv_col in fields_mapping.items(): + if model_field == 'name': + continue + v = row.get(csv_col) + if v: + defaults[model_field] = v + + rank_title = rank_mapping.get('title', rank_name.capitalize()) + + # Get the rank by the column name. + # Skip creating on this rank if it doesn't exist + tree_def_item = context.tree_def_item_map.get(rank_name) + + if tree_def_item is None: + continue + + # Create the node at this rank if it isn't already there. + obj = tree_node_model.objects.filter( + name=record_name, + fullname=record_name, + definition=tree_def, + definitionitem=tree_def_item, + parent=parent, + ).first() + if obj is None: + data = { + 'name': record_name, + 'fullname': record_name, + 'definition': tree_def, + 'definitionitem': tree_def_item, + 'parent': parent, + 'rankid': tree_def_item.rankid, + **defaults + } + obj = tree_node_model(**data) + obj.save(skip_tree_extras=True) + + parent = obj + rank_id += 10 + +@app.task(base=LogErrorsTask, bind=True) +def create_default_tree_task(self, url: str, discipline_id: int, tree_discipline_name: str, specify_collection_id: Optional[int], + specify_user_id: Optional[int], tree_cfg: dict, row_count: Optional[int], initial_tree_name: str, + existing_tree_def_id = None): + logger.info(f'starting task {str(self.request.id)}') + + discipline = None + if discipline_id: + discipline = spmodels.Discipline.objects.get(id=discipline_id) + tree_name = initial_tree_name # Name will be uniquified on tree creation + + logger.debug("CREATING TREE:") + logger.debug(tree_name) + + if specify_user_id and specify_collection_id: + specify_user = spmodels.Specifyuser.objects.get(id=specify_user_id) + Message.objects.create( + user=specify_user, + content=json.dumps({ + 'type': 'create-default-tree-starting', + 'name': initial_tree_name, + 'taskid': str(self.request.id), + 'collection_id': specify_collection_id, + }) + ) + + current = 0 + total = 1 + def progress(cur: int, additional_total: int=0) -> None: + nonlocal current, total + current += cur + total += additional_total + if current > total: + current = total + self.update_state(state='RUNNING', meta={'current': current, 'total': total}) + + try: + with transaction.atomic(): + tree_type = 'taxon' + if tree_discipline_name in SPECIFY_TREES: + # non-taxon tree + tree_type = tree_discipline_name + + tree_def = None + if existing_tree_def_id: + # Import into exisiting tree + tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) + tree_def = tree_def_model.objects.filter(pk=existing_tree_def_id).first() + + logger.debug("treedef") + logger.debug(existing_tree_def_id) + logger.debug(tree_def is None) + + if tree_def is None: + # Create a new empty tree. Get rank configuration from the mapping. + full_name_direction = 1 + if tree_type in ('geologictimeperiod',): + full_name_direction = -1 + + rank_cfg = [{ + 'name': 'Root', + 'enforced': True, + 'rank': 0, + **tree_cfg.get('root', {}) + }] + auto_rank_id = 10 + for rank in tree_cfg['ranks']: + rank_cfg.append({ + 'name': rank['name'], + 'enforced': rank.get('enforced', True), + 'infullname': rank.get('infullname', False), + 'fullnameseparator': rank.get('fullnameseparator', ' '), + 'rank': rank.get('rank', auto_rank_id) + }) + auto_rank_id += 10 + tree_def = initialize_default_tree(tree_type, discipline, initial_tree_name, rank_cfg, full_name_direction) + + tree_name = tree_def.name + + logger.debug(tree_name) + + # Start importing CSV data + context = DefaultTreeContext(tree_type, tree_name) + + total_rows = 0 + if row_count: + total_rows = row_count-2 + progress(0, total_rows) + + for row in stream_csv_from_url(url): + add_default_tree_record(context, row, tree_cfg) + progress(1, 0) + except Exception as e: + if specify_user_id and specify_collection_id: + Message.objects.create( + user=specify_user, + content=json.dumps({ + 'type': 'create-default-tree-failed', + 'name': tree_name, + 'taskid': str(self.request.id), + 'collection_id': specify_collection_id, + # 'error': str(e) + }) + ) + raise + + if specify_user_id and specify_collection_id: + Message.objects.create( + user=specify_user, + content=json.dumps({ + 'type': 'create-default-tree-completed', + 'name': tree_name, + 'taskid': str(self.request.id), + 'collection_id': specify_collection_id, + }) + ) + +def stream_csv_from_url(url: str) -> Iterator[Dict[str, str]]: + """ + Streams a taxon CSV from a URL. Yields each row. + """ + chunk_size = 8192 + max_retries = 10 + + def lines_iter() -> Iterator[str]: + # Streams data from the server in -chunks-, yields -lines-. + buffer = b"" + bytes_downloaded = 0 + retries = 0 + + headers = {} + while True: + # Request data starting from the last downloaded bytes + if bytes_downloaded > 0: + headers['Range'] = f'bytes={bytes_downloaded}-' + + try: + with requests.get(url, stream=True, timeout=(5, 30), headers=headers) as resp: + resp.raise_for_status() + for chunk in resp.iter_content(chunk_size=chunk_size): + chunk_length = len(chunk) + if chunk_length == 0: + continue + buffer += chunk + bytes_downloaded += chunk_length + + # Extract all lines from chunk + while True: + new_line_index = buffer.find(b'\n') + if new_line_index == -1: break + line = buffer[:new_line_index + 1] # extract line + buffer = buffer[new_line_index + 1 :] # clear read buffer + yield line.decode('utf-8-sig', errors='replace') + + if buffer: + # yield last line + yield buffer.decode('utf-8-sig', errors='replace') + return + except (ChunkedEncodingError, ConnectionError) as e: + # Trigger retry + if retries < max_retries: + retries += 1 + time.sleep(2 ** retries) + continue + raise + except Exception: + raise + + reader = csv.DictReader(lines_iter()) + + for row in reader: + yield row \ No newline at end of file diff --git a/specifyweb/backend/trees/utils.py b/specifyweb/backend/trees/utils.py index 0dba30d985f..621571c5304 100644 --- a/specifyweb/backend/trees/utils.py +++ b/specifyweb/backend/trees/utils.py @@ -1,15 +1,5 @@ -from typing import Any, Callable, Dict, Iterator, Optional, TypedDict, NotRequired -import json -import requests -import csv -import time -from requests.exceptions import ChunkedEncodingError, ConnectionError - -from django.db import transaction from django.db.models import Q, Count, Model -from specifyweb.backend.notifications.models import Message -from specifyweb.celery_tasks import LogErrorsTask, app import specifyweb.specify.models as spmodels from specifyweb.specify.datamodel import datamodel, Table @@ -107,302 +97,3 @@ def get_models(name: str): return tree_def_model, tree_rank_model, tree_node_model -class RankConfiguration(TypedDict): - name: str - title: NotRequired[str] - enforced: bool - infullname: bool - fullnameseparator: NotRequired[str] - rank: int # rank id - -def initialize_default_tree(tree_type: str, discipline_or_institution, tree_name: str, rank_cfg: list[RankConfiguration], full_name_direction: int=1): - """Creates an initial empty tree.""" - with transaction.atomic(): - tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) - - # Uniquify name - tree_def = None - unique_tree_name = tree_name - if tree_def_model.objects.filter(name=tree_name).exists(): - i = 1 - while tree_def_model.objects.filter(name=f"{tree_name}_{i}").exists(): - i += 1 - unique_tree_name = f"{tree_name}_{i}" - - # Create tree definition - if tree_type == 'storage': - scope = { - 'institution': discipline_or_institution - } - else: - scope = { - 'discipline': discipline_or_institution - } - - tree_def, _ = tree_def_model.objects.get_or_create( - name=unique_tree_name, - fullnamedirection=full_name_direction, - **scope - ) - - # Create tree ranks - treedefitems_bulk = [] - rank_id = 0 - for rank in rank_cfg: - treedefitems_bulk.append( - tree_rank_model( - treedef=tree_def, - name=rank.get('name'), - title=rank.get('title', rank['name'].title()), - rankid=int(rank.get('rank', rank_id)), - isenforced=rank.get('enforced', True), - isinfullname=rank.get('infullname', False), - fullnameseparator=rank.get('fullnameseparator', ' ') - ) - ) - rank_id += 10 - if treedefitems_bulk: - tree_rank_model.objects.bulk_create(treedefitems_bulk, ignore_conflicts=False) - - # Create root node - # TODO: Avoid having duplicated code from add_root endpoint - root_rank = tree_rank_model.objects.get(treedef=tree_def, rankid=0) - tree_node, _ = tree_node_model.objects.get_or_create( - name=TREE_ROOT_NODES.get(tree_type, "Root"), - fullname=TREE_ROOT_NODES.get(tree_type, "Root"), - nodenumber=1, - definition=tree_def, - definitionitem=root_rank, - parent=None - ) - - return tree_def - -class RankMappingConfiguration(TypedDict): - name: str - column: str - enforced: NotRequired[bool] - rank: NotRequired[int] - infullname: NotRequired[bool] - fullnameseparator: NotRequired[str] - fields: Dict[str, str] - -def add_default_tree_record(tree_type: str, row: dict, tree_name: str, tree_cfg: dict[str, RankMappingConfiguration]): - """ - Given one CSV row and a column mapping / rank configuration dictionary, - walk through the 'ranks' in order, creating or updating each tree record and linking - it to its parent. - """ - tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) - tree_def = tree_def_model.objects.get(name=tree_name) - parent = tree_node_model.objects.filter(definitionitem__rankid=0, definition=tree_def).first() - rank_id = 10 - - for rank_map in tree_cfg['ranks']: - rank_name = rank_map['name'] - fields_map = rank_map['fields'] - - record_name = row.get(rank_map.get('column', rank_name)) # Record's name is in the column. - - if not record_name: - continue # This row doesn't contain a record for this rank. - - defaults = {} - for model_field, csv_col in fields_map.items(): - if model_field == 'name': - continue - v = row.get(csv_col) - if v: - defaults[model_field] = v - - rank_title = rank_map.get('title', rank_name.capitalize()) - - # Get the rank by the column name. - # It should already exist by this point, but worst case it will be generated here. - treedef_item, _ = tree_rank_model.objects.get_or_create( - name=rank_name, - treedef=tree_def, - defaults={ - 'title': rank_title, - 'rankid': rank_id - } - ) - - # Create the record at this rank if it isn't already there. - obj = tree_node_model.objects.filter( - name=record_name, - fullname=record_name, - definition=tree_def, - definitionitem=treedef_item, - parent=parent, - ).first() - if obj is None: - data = { - 'name': record_name, - 'fullname': record_name, - 'definition': tree_def, - 'definitionitem': treedef_item, - 'parent': parent, - 'rankid': treedef_item.rankid, - **defaults - } - obj = tree_node_model(**data) - obj.save(skip_tree_extras=True) - - # if not taxon_obj and defaults: - # for f, v in defaults.items(): - # setattr(taxon_obj, f, v) - # taxon_obj.save() - - parent = obj - rank_id += 10 - -@app.task(base=LogErrorsTask, bind=True) -def create_default_tree_task(self, url: str, discipline_id: int, tree_discipline_name: str, specify_collection_id: int, - specify_user_id: Optional[int], tree_cfg: dict, row_count: Optional[int], initial_tree_name: str): - logger.info(f'starting task {str(self.request.id)}') - - discipline = spmodels.Discipline.objects.get(id=discipline_id) - tree_name = initial_tree_name # Name will be uniquified on tree creation - - if specify_user_id: - specify_user = spmodels.Specifyuser.objects.get(id=specify_user_id) - Message.objects.create( - user=specify_user, - content=json.dumps({ - 'type': 'create-default-tree-starting', - 'name': initial_tree_name, - 'taskid': str(self.request.id), - 'collection_id': specify_collection_id, - }) - ) - - current = 0 - total = 1 - def progress(cur: int, additional_total: int=0) -> None: - nonlocal current, total - current += cur - total += additional_total - if current > total: - current = total - self.update_state(state='RUNNING', meta={'current': current, 'total': total}) - - try: - with transaction.atomic(): - tree_type = 'taxon' - if tree_discipline_name in SPECIFY_TREES: - # non-taxon tree - tree_type = tree_discipline_name - - # Create a new empty tree. Get rank configuration from the mapping. - full_name_direction = 1 - if tree_type in ('geologictimeperiod',): - full_name_direction = -1 - - rank_cfg = [{ - 'name': 'Root', - 'enforced': True, - 'rank': 0, - **tree_cfg.get('root', {}) - }] - auto_rank_id = 10 - for rank in tree_cfg['ranks']: - rank_cfg.append({ - 'name': rank['name'], - 'enforced': rank.get('enforced', True), - 'infullname': rank.get('infullname', False), - 'fullnameseparator': rank.get('fullnameseparator', ' '), - 'rank': rank.get('rank', auto_rank_id) - }) - auto_rank_id += 10 - tree_def = initialize_default_tree(tree_type, discipline, initial_tree_name, rank_cfg, full_name_direction) - tree_name = tree_def.name - - # Start importing CSV data - total_rows = 0 - if row_count: - total_rows = row_count-2 - progress(0, total_rows) - for row in stream_csv_from_url(url): - add_default_tree_record(tree_type, row, tree_name, tree_cfg) - progress(1, 0) - except Exception as e: - if specify_user_id: - Message.objects.create( - user=specify_user, - content=json.dumps({ - 'type': 'create-default-tree-failed', - 'name': tree_name, - 'taskid': str(self.request.id), - 'collection_id': specify_collection_id, - # 'error': str(e) - }) - ) - raise - - if specify_user_id: - Message.objects.create( - user=specify_user, - content=json.dumps({ - 'type': 'create-default-tree-completed', - 'name': tree_name, - 'taskid': str(self.request.id), - 'collection_id': specify_collection_id, - }) - ) - -def stream_csv_from_url(url: str) -> Iterator[Dict[str, str]]: - """ - Streams a taxon CSV from a URL. Yields each row. - """ - chunk_size = 8192 - max_retries = 10 - - def lines_iter() -> Iterator[str]: - # Streams data from the server in -chunks-, yields -lines-. - buffer = b"" - bytes_downloaded = 0 - retries = 0 - - headers = {} - while True: - # Request data starting from the last downloaded bytes - if bytes_downloaded > 0: - headers['Range'] = f'bytes={bytes_downloaded}-' - - try: - with requests.get(url, stream=True, timeout=(5, 30), headers=headers) as resp: - resp.raise_for_status() - for chunk in resp.iter_content(chunk_size=chunk_size): - chunk_length = len(chunk) - if chunk_length == 0: - continue - buffer += chunk - bytes_downloaded += chunk_length - - # Extract all lines from chunk - while True: - new_line_index = buffer.find(b'\n') - if new_line_index == -1: break - line = buffer[:new_line_index + 1] # extract line - buffer = buffer[new_line_index + 1 :] # clear read buffer - yield line.decode('utf-8-sig', errors='replace') - - if buffer: - # yield last line - yield buffer.decode('utf-8-sig', errors='replace') - return - except (ChunkedEncodingError, ConnectionError) as e: - # Trigger retry - if retries < max_retries: - retries += 1 - time.sleep(2 ** retries) - continue - raise - except Exception: - raise - - reader = csv.DictReader(lines_iter()) - - for row in reader: - yield row \ No newline at end of file diff --git a/specifyweb/backend/trees/views.py b/specifyweb/backend/trees/views.py index 2555438ed9f..1c7379fcbd3 100644 --- a/specifyweb/backend/trees/views.py +++ b/specifyweb/backend/trees/views.py @@ -28,7 +28,8 @@ from specifyweb.backend.stored_queries.group_concat import group_concat from specifyweb.backend.notifications.models import Message -from specifyweb.backend.trees.utils import add_default_tree_record, create_default_tree_task, get_search_filters, stream_csv_from_url +from specifyweb.backend.trees.utils import get_search_filters +from specifyweb.backend.trees.defaults import create_default_tree_task from specifyweb.specify.utils.field_change_info import FieldChangeInfo from specifyweb.backend.trees.ranks import tree_rank_count from . import extras From 53a3029b1acd191d562c30da058a1f6d66f0fecb Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 15 Jan 2026 09:24:10 -0600 Subject: [PATCH 21/52] Preload taxon tree according to discipline type --- specifyweb/backend/setup_tool/setup_tasks.py | 8 +-- .../backend/setup_tool/tree_defaults.py | 66 ++++++++++++++----- specifyweb/backend/trees/defaults.py | 9 --- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py index 871ba958306..2f95e922a6e 100644 --- a/specifyweb/backend/setup_tool/setup_tasks.py +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -144,14 +144,14 @@ def update_progress(): logger.debug('Finalizing database') fix_schema_config() create_app_resource_defaults() + + # Pre-load trees + logger.debug('Starting default tree downloads') if is_paleo_geo: - preload_default_tree('Geologictimeperiod', discipline_id, collection_id, chronostrat_treedef_id, specifyuser_id) - logger.debug(data['geographytreedef']) + preload_default_tree('Geologictimeperiod', discipline_id, collection_id, chronostrat_treedef_id, specifyuser_id) if data['geographytreedef'].get('preload'): - logger.debug("trying to create geography tree") preload_default_tree('Geography', discipline_id, collection_id, geography_treedef_id, specifyuser_id) if data['taxontreedef'].get('preload'): - logger.debug('trying to create taxon tree') preload_default_tree('Taxon', discipline_id, collection_id, taxon_treedef_id, specifyuser_id) update_progress() diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 096b917258f..859f9a45320 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -20,9 +20,10 @@ 'Lithostrat': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'lithostrat_tree.json', 'Tectonicunit': Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'tectonicunit_tree.json' } +DEFAULT_TAXON_TREE_LIST_URL = 'https://files.specifysoftware.org/taxonfiles/taxonfiles.json' DEFAULT_TREE_URLS = { 'Geography': 'https://files.specifysoftware.org/geographyfiles/geonames.csv', - 'Geologictimeperiod': 'https://files.specifysoftware.org/treerows/geography.json', + 'Geologictimeperiod': 'https://files.specifysoftware.org/chronostratfiles/GeologicTimePeriod.csv', } DEFAULT_TREE_MAPPING_URLS = { 'Geography': 'https://files.specifysoftware.org/treerows/geography.json', @@ -76,23 +77,52 @@ def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, prelo return tree_def def preload_default_tree(tree_type: str, discipline_id: Optional[int], collection_id: Optional[int], tree_def_id: int, specify_user_id: Optional[int]): - """Automatically creates a populated default tree.""" - # Tree download config: - tree_discipline_name = tree_type.lower() - tree_name = tree_type.title() - # Tree file urls - url = DEFAULT_TREE_URLS.get(tree_type) - mapping_url = DEFAULT_TREE_MAPPING_URLS.get(tree_type) - resp = requests.get(mapping_url) - resp.raise_for_status() - tree_cfg = resp.json() - - task_id = str(uuid4()) - create_default_tree_task.apply_async( - args=[url, discipline_id, tree_discipline_name, collection_id, specify_user_id, tree_cfg, 1000000, tree_name, tree_def_id], - task_id=f"create_default_tree_{tree_type}_{task_id}", - taskid=task_id - ) + """Creates a populated default tree without user input.""" + try: + # Tree download config: + tree_discipline_name = tree_type.lower() + tree_name = tree_type.title() + # Tree file urls + url = DEFAULT_TREE_URLS.get(tree_type) + mapping_url = DEFAULT_TREE_MAPPING_URLS.get(tree_type) + + if tree_type.lower() == 'taxon': + discipline = Discipline.objects.filter(pk=discipline_id).first() + tree_discipline_name = discipline.type + + # Retrieve taxon tree list to find an appropriate one. + # Schema described in CreateTree.tsx + logger.debug(f'Fetching default taxon list from {DEFAULT_TAXON_TREE_LIST_URL}') + resp = requests.get(DEFAULT_TAXON_TREE_LIST_URL) + resp.raise_for_status() + taxon_tree_list = resp.json() + + for tree in taxon_tree_list: + if tree.get('discipline') == tree_discipline_name: + logger.debug(f'Found matching default taxon url for {tree_discipline_name}') + url = tree.get('file') + mapping_url = tree.get('mappingFile') + tree_name = tree.get('title') + break + + if not url or not mapping_url: + logger.warning(f'Can\'t preload tree, no default tree URLs for {tree_discipline_name} tree.') + return + + resp = requests.get(mapping_url) + resp.raise_for_status() + tree_cfg = resp.json() + + task_id = str(uuid4()) + create_default_tree_task.apply_async( + args=[url, discipline_id, tree_discipline_name, collection_id, specify_user_id, tree_cfg, 1000000, tree_name, tree_def_id], + task_id=f"create_default_tree_{tree_type}_{task_id}", + taskid=task_id + ) + except Exception as e: + # Give up if there's an error to avoid resetting the entire setup. + logger.warning(f'Error trying to preload {tree_type} tree: {e}') + return def update_tree_scoping(treedef: Type[DjangoModel], discipline_id: int): """Trees may be created before a discipline is created. This will update their discipline.""" diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py index 081b953af0f..64595a9d16f 100644 --- a/specifyweb/backend/trees/defaults.py +++ b/specifyweb/backend/trees/defaults.py @@ -191,9 +191,6 @@ def create_default_tree_task(self, url: str, discipline_id: int, tree_discipline discipline = spmodels.Discipline.objects.get(id=discipline_id) tree_name = initial_tree_name # Name will be uniquified on tree creation - logger.debug("CREATING TREE:") - logger.debug(tree_name) - if specify_user_id and specify_collection_id: specify_user = spmodels.Specifyuser.objects.get(id=specify_user_id) Message.objects.create( @@ -229,10 +226,6 @@ def progress(cur: int, additional_total: int=0) -> None: tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) tree_def = tree_def_model.objects.filter(pk=existing_tree_def_id).first() - logger.debug("treedef") - logger.debug(existing_tree_def_id) - logger.debug(tree_def is None) - if tree_def is None: # Create a new empty tree. Get rank configuration from the mapping. full_name_direction = 1 @@ -258,8 +251,6 @@ def progress(cur: int, additional_total: int=0) -> None: tree_def = initialize_default_tree(tree_type, discipline, initial_tree_name, rank_cfg, full_name_direction) tree_name = tree_def.name - - logger.debug(tree_name) # Start importing CSV data context = DefaultTreeContext(tree_type, tree_name) From 870d69990600b3dcc6bf83ea5fccd9ccabb2b45f Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 15 Jan 2026 10:58:34 -0600 Subject: [PATCH 22/52] Create empty default tree for new disciplines --- specifyweb/backend/setup_tool/api.py | 8 ++++++ specifyweb/backend/setup_tool/setup_tasks.py | 26 +++++++++--------- specifyweb/backend/trees/defaults.py | 29 ++++++++++++-------- specifyweb/backend/trees/views.py | 9 +++++- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/specifyweb/backend/setup_tool/api.py b/specifyweb/backend/setup_tool/api.py index 4ce8d76d708..dfbfe9e2cd7 100644 --- a/specifyweb/backend/setup_tool/api.py +++ b/specifyweb/backend/setup_tool/api.py @@ -193,6 +193,8 @@ def create_discipline(data): existing_discipline = Discipline.objects.filter(id=existing_id).first() if existing_discipline: return {"discipline_id": existing_discipline.id} + + is_first_discipline = Discipline.objects.count() == 0 # Resolve division division_url = data.get('division') @@ -246,6 +248,12 @@ def create_discipline(data): update_tree_scoping(geographytreedef, new_discipline.id) update_tree_scoping(geologictimeperiodtreedef, new_discipline.id) + # Create a default taxon tree if the database is already set up. + if not is_first_discipline: + create_taxon_tree({ + 'discipline_id': new_discipline.id + }) + return {"discipline_id": new_discipline.id} except Exception as e: diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py index 2f95e922a6e..ccfa1f8ac17 100644 --- a/specifyweb/backend/setup_tool/setup_tasks.py +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -81,15 +81,15 @@ def update_progress(): logger.debug('## SETTING UP DATABASE WITH SETTINGS:##') logger.debug(data) - logger.debug('Creating institution') + logger.info('Creating institution') api.create_institution(data['institution']) update_progress() - logger.debug('Creating storage tree') + logger.info('Creating storage tree') api.create_storage_tree(data['storagetreedef']) update_progress() - logger.debug('Creating division') + logger.info('Creating division') api.create_division(data['division']) update_progress() @@ -101,52 +101,52 @@ def update_progress(): # if is_paleo_geo: # Create an empty chronostrat tree no matter what because discipline needs it. - logger.debug('Creating Chronostratigraphy tree') + logger.info('Creating Chronostratigraphy tree') default_chronostrat_tree = default_tree.copy() default_chronostrat_tree['fullnamedirection'] = -1 chronostrat_result = api.create_geologictimeperiod_tree(default_chronostrat_tree) chronostrat_treedef_id = chronostrat_result.get('treedef_id') - logger.debug('Creating geography tree') + logger.info('Creating geography tree') uses_global_geography_tree = data['institution'].get('issinglegeographytree', False) geography_result = api.create_geography_tree(data['geographytreedef'].copy(), global_tree=uses_global_geography_tree) geography_treedef_id = geography_result.get('treedef_id') - logger.debug('Creating discipline') + logger.info('Creating discipline') discipline_result = api.create_discipline(data['discipline']) discipline_id = discipline_result.get('discipline_id') default_tree['discipline_id'] = discipline_id update_progress() if is_paleo_geo: - logger.debug('Creating Lithostratigraphy tree') + logger.info('Creating Lithostratigraphy tree') api.create_lithostrat_tree(default_tree.copy()) - logger.debug('Creating Tectonic Unit tree') + logger.info('Creating Tectonic Unit tree') api.create_tectonicunit_tree(default_tree.copy()) - logger.debug('Creating taxon tree') + logger.info('Creating taxon tree') if data['taxontreedef'].get('discipline_id') is None: data['taxontreedef']['discipline_id'] = discipline_id taxon_result = api.create_taxon_tree(data['taxontreedef'].copy()) taxon_treedef_id = taxon_result.get('treedef_id') update_progress() - logger.debug('Creating collection') + logger.info('Creating collection') collection_result = api.create_collection(data['collection']) collection_id = collection_result.get('collection_id') update_progress() - logger.debug('Creating specify user') + logger.info('Creating specify user') specifyuser_result = api.create_specifyuser(data['specifyuser']) specifyuser_id = specifyuser_result.get('user_id') - logger.debug('Finalizing database') + logger.info('Finalizing database') fix_schema_config() create_app_resource_defaults() # Pre-load trees - logger.debug('Starting default tree downloads') + logger.info('Starting default tree downloads') if is_paleo_geo: preload_default_tree('Geologictimeperiod', discipline_id, collection_id, chronostrat_treedef_id, specifyuser_id) if data['geographytreedef'].get('preload'): diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py index 64595a9d16f..8a88bfa06c3 100644 --- a/specifyweb/backend/trees/defaults.py +++ b/specifyweb/backend/trees/defaults.py @@ -74,20 +74,25 @@ def initialize_default_tree(tree_type: str, discipline_or_institution, tree_name if treedefitems_bulk: tree_rank_model.objects.bulk_create(treedefitems_bulk, ignore_conflicts=False) - # Create root node - # TODO: Avoid having duplicated code from add_root endpoint - root_rank = tree_rank_model.objects.get(treedef=tree_def, rankid=0) - tree_node, _ = tree_node_model.objects.get_or_create( - name=TREE_ROOT_NODES.get(tree_type, "Root"), - fullname=TREE_ROOT_NODES.get(tree_type, "Root"), - nodenumber=1, - definition=tree_def, - definitionitem=root_rank, - parent=None - ) + create_default_root(tree_def, tree_type) return tree_def +def create_default_root(tree_def, tree_type: str): + """Create root node""" + # TODO: Avoid having duplicated code from add_root endpoint + tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) + root_rank = tree_rank_model.objects.get(treedef=tree_def, rankid=0) + tree_node, _ = tree_node_model.objects.get_or_create( + name=TREE_ROOT_NODES.get(tree_type, "Root"), + fullname=TREE_ROOT_NODES.get(tree_type, "Root"), + nodenumber=1, + definition=tree_def, + definitionitem=root_rank, + parent=None + ) + return tree_node + class RankMappingConfiguration(TypedDict): name: str column: str @@ -225,6 +230,8 @@ def progress(cur: int, additional_total: int=0) -> None: # Import into exisiting tree tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) tree_def = tree_def_model.objects.filter(pk=existing_tree_def_id).first() + if tree_def: + create_default_root(tree_def, tree_type) if tree_def is None: # Create a new empty tree. Get rank configuration from the mapping. diff --git a/specifyweb/backend/trees/views.py b/specifyweb/backend/trees/views.py index 1c7379fcbd3..eba1f68a17e 100644 --- a/specifyweb/backend/trees/views.py +++ b/specifyweb/backend/trees/views.py @@ -661,6 +661,10 @@ def has_tree_read_permission(tree: TREE_TABLE) -> bool: "type": "integer", "description": "The total number of rows contained in the CSV file. Only used for progress tracking." }, + "treeDefId": { + "type": "integer", + "description": "(optional) The ID of the existing tree to import into." + } }, "required": ["url", "disciplineName"], "oneOf": [ @@ -738,6 +742,9 @@ def create_default_tree_view(request): if not url: return http.JsonResponse({'error': 'Tree not found.'}, status=404) + # Import into an existing tree + tree_def_id = data.get('treeDefId') + # CSV mapping. Accept the mapping directly or a url to a JSON file containing the mapping. tree_cfg = data.get('mapping', None) mapping_url = data.get('mappingUrl', None) @@ -755,7 +762,7 @@ def create_default_tree_view(request): task_id = str(uuid4()) async_result = create_default_tree_task.apply_async( - args=[url, discipline.id, tree_discipline_name, request.specify_collection.id, request.specify_user.id, tree_cfg, row_count, tree_name], + args=[url, discipline.id, tree_discipline_name, request.specify_collection.id, request.specify_user.id, tree_cfg, row_count, tree_name, tree_def_id], task_id=f"create_default_tree_{tree_discipline_name}_{task_id}", taskid=task_id ) From d1c711c79be294c3ff93fd284d6c0a8ccf601273 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 15 Jan 2026 11:38:51 -0600 Subject: [PATCH 23/52] Remove root from new taxon trees --- specifyweb/backend/trees/defaults.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py index 8a88bfa06c3..0c68ad48ff4 100644 --- a/specifyweb/backend/trees/defaults.py +++ b/specifyweb/backend/trees/defaults.py @@ -74,7 +74,10 @@ def initialize_default_tree(tree_type: str, discipline_or_institution, tree_name if treedefitems_bulk: tree_rank_model.objects.bulk_create(treedefitems_bulk, ignore_conflicts=False) - create_default_root(tree_def, tree_type) + # Create a root node + # New taxon trees are expected to be empty + if tree_type != 'taxon': + create_default_root(tree_def, tree_type) return tree_def @@ -230,8 +233,6 @@ def progress(cur: int, additional_total: int=0) -> None: # Import into exisiting tree tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) tree_def = tree_def_model.objects.filter(pk=existing_tree_def_id).first() - if tree_def: - create_default_root(tree_def, tree_type) if tree_def is None: # Create a new empty tree. Get rank configuration from the mapping. @@ -257,6 +258,7 @@ def progress(cur: int, additional_total: int=0) -> None: auto_rank_id += 10 tree_def = initialize_default_tree(tree_type, discipline, initial_tree_name, rank_cfg, full_name_direction) + create_default_root(tree_def, tree_type) tree_name = tree_def.name # Start importing CSV data From 683d6e9fa6ce3f257a267ec6a544fa1632c7064f Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 15 Jan 2026 12:13:18 -0600 Subject: [PATCH 24/52] Add button to import tree into an empty tree --- .../lib/components/TreeView/CreateTree.tsx | 226 ++++++++++++++---- .../js_src/lib/components/TreeView/Tree.tsx | 21 +- 2 files changed, 190 insertions(+), 57 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx index 690e72402e4..0e870ddd0c8 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx @@ -71,22 +71,6 @@ export function CreateTree< SpecifyResource | undefined >(undefined); - const [treeOptions, setTreeOptions] = React.useState< - TaxonFileDefaultList | undefined - >(undefined); - - // Fetch list of available default trees. - React.useEffect(() => { - fetch('https://files.specifysoftware.org/taxonfiles/taxonfiles.json') - .then(async (response) => response.json()) - .then((data: TaxonFileDefaultList) => { - setTreeOptions(data); - }) - .catch((error) => { - console.error('Failed to fetch tree options:', error); - }); - }, []); - const connectedCollection = getSystemInfo().collection; // Start default tree creation @@ -158,44 +142,19 @@ export function CreateTree< >
-
    -

    {treeText.populatedTrees()}

    - {treeOptions === undefined - ? undefined - : treeOptions.map((resource, index) => ( -
  • - { - loading( - handleClick(resource).catch(console.error) - ); - }} - > - {localized(resource.title)} - -
    - {resource.description} -
    -
    - {`Source: ${resource.src}`} -
    -
  • - ))} -
+ { + loading( + handleClick(resource).catch(console.error) + )} + } + />
-
    -

    {treeText.emptyTrees()}

    - {defaultTreeDefs.map((resource, index) => ( -
  • - handleClickEmptyTree(resource)} - > - {localized(resource.name)} - -
  • - ))} -
+
<> @@ -232,6 +191,169 @@ export function CreateTree< ); } +export function ImportTree< + SCHEMA extends AnyTree, +>({ + tableName, + treeDefId, +}: { + readonly tableName: SCHEMA['tableName']; + readonly treeDefId: Number; +}): JSX.Element { + const loading = React.useContext(LoadingContext); + const [isActive, setIsActive] = React.useState(0); + const [isTreeCreationStarted, setIsTreeCreationStarted] = React.useState(false); + const [treeCreationTaskId, setTreeCreationTaskId] = React.useState(undefined); + + const connectedCollection = getSystemInfo().collection; + + const handleClick = async (resource: TaxonFileDefaultDefinition): Promise => { + setIsTreeCreationStarted(true); + return ajax('/trees/create_default_tree/', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + url: resource.file, + mappingUrl: resource.mappingFile, + collection: connectedCollection, + disciplineName: resource.discipline, + rowCount: resource.rows, + treeName: resource.title, + treeDefId: treeDefId + }, + }) + .then(({ data, status }) => { + if (status === Http.OK) { + console.log(`${resource.title} tree created successfully:`, data); + } else if (status === Http.ACCEPTED) { + // Tree is being created in the background. + console.log(`${resource.title} tree creation started successfully:`, data); + setTreeCreationTaskId(data.task_id); + } + }) + .catch((error) => { + console.error(`Request failed for ${resource.file}:`, error); + throw error; + }); + } + + return ( + <> + {tableName === 'Taxon' && userInformation.isadmin ? ( + { + setIsActive(1); + }} + > + {commonText.import()} + + ) : null} + {isActive === 1 ? ( + + {commonText.cancel()} + + } + header={commonText.import()} + onClose={() => setIsActive(0)} + > +
+ { + loading( + handleClick(resource).catch(console.error) + )} + } + /> +
+ <> + {isTreeCreationStarted && treeCreationTaskId ? ( + { + setIsTreeCreationStarted(false); + setTreeCreationTaskId(undefined); + setIsActive(0); + }} + onStopped={() => { + setIsTreeCreationStarted(false); + setTreeCreationTaskId(undefined); + }} + /> + ) : undefined} + +
+ ) : null} + + ); +} + +function EmptyTreeList({ + handleClick, +}: { + readonly handleClick: (resource: DeepPartial>) => void; +}): JSX.Element { + return
    +

    {treeText.emptyTrees()}

    + {defaultTreeDefs.map((resource, index) => ( +
  • + handleClick(resource)} + > + {localized(resource.name)} + +
  • + ))} +
+} + +function PopulatedTreeList({ + handleClick, +}: { + readonly handleClick: (resource: TaxonFileDefaultDefinition) => void; +}): JSX.Element { + const [treeOptions, setTreeOptions] = React.useState< + TaxonFileDefaultList | undefined + >(undefined); + + // Fetch list of available default trees. + React.useEffect(() => { + fetch('https://files.specifysoftware.org/taxonfiles/taxonfiles.json') + .then(async (response) => response.json()) + .then((data: TaxonFileDefaultList) => { + setTreeOptions(data); + }) + .catch((error) => { + console.error('Failed to fetch tree options:', error); + }); + }, []); + + return
    +

    {treeText.populatedTrees()}

    + {treeOptions === undefined + ? undefined + : treeOptions.map((resource, index) => ( +
  • + handleClick(resource)} + > + {localized(resource.title)} + +
    + {resource.description} +
    +
    + {`Source: ${resource.src}`} +
    +
  • + ))} +
+} + export function TreeCreationProgressDialog({ taskId, onClose, diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index b343a725731..0c9d525cc23 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -26,6 +26,7 @@ import { AddRank } from './AddRank'; import type { Conformations, Row, Stats } from './helpers'; import { fetchStats } from './helpers'; import { TreeRow } from './Row'; +import { ImportTree } from './CreateTree' const treeToPref = { Geography: 'geography', @@ -218,11 +219,21 @@ export function Tree<
{rows.length === 0 ? ( - +
+ + {treeText.addRootNode()} + + {treeDefId ? ( + + ) : null} +
) : undefined}
    {rows.map((row, index) => ( From 644e78c5d6b9008b33ba478bb096ea4e2046460f Mon Sep 17 00:00:00 2001 From: alesan99 Date: Thu, 15 Jan 2026 12:29:30 -0600 Subject: [PATCH 25/52] fix tests --- .../js_src/lib/components/TreeView/CreateTree.tsx | 4 +--- .../frontend/js_src/lib/components/TreeView/Tree.tsx | 4 +--- specifyweb/frontend/js_src/lib/localization/tree.ts | 11 ----------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx index 0e870ddd0c8..4ec3e5927ef 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx @@ -246,9 +246,7 @@ export function ImportTree< onClick={() => { setIsActive(1); }} - > - {commonText.import()} - + /> ) : null} {isActive === 1 ? ( - {treeText.addRootNode()} - + /> {treeDefId ? ( Date: Thu, 15 Jan 2026 13:09:39 -0600 Subject: [PATCH 26/52] Don't create root if it already exists --- specifyweb/backend/trees/defaults.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py index 0c68ad48ff4..62d96ebbb3b 100644 --- a/specifyweb/backend/trees/defaults.py +++ b/specifyweb/backend/trees/defaults.py @@ -86,6 +86,16 @@ def create_default_root(tree_def, tree_type: str): # TODO: Avoid having duplicated code from add_root endpoint tree_def_model, tree_rank_model, tree_node_model = get_models(tree_type) root_rank = tree_rank_model.objects.get(treedef=tree_def, rankid=0) + + # Don't create a root if one already exists + existing_root = tree_node_model.objects.filter( + definition=tree_def, + definitionitem=root_rank + ).first() + + if existing_root: + return existing_root + tree_node, _ = tree_node_model.objects.get_or_create( name=TREE_ROOT_NODES.get(tree_type, "Root"), fullname=TREE_ROOT_NODES.get(tree_type, "Root"), From 20e9a3885001d2ae54353bdf8814ec33669be26b Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Fri, 16 Jan 2026 08:58:15 -0500 Subject: [PATCH 27/52] Fix: Chnage full name separator to be a space --- .../components/SetupTool/setupResources.ts | 91 ++++++++++++++----- 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index cf17ab0e748..dde2c3a17a6 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -57,12 +57,17 @@ export const disciplineTypeOptions = [ { value: 'geology', label: 'Geology' }, ]; -// Must match config/backstop/uiformatters.xml -// TODO: Fetch uiformatters.xml from the backend instead and use UIFormatter.placeholder +/* + * Must match config/backstop/uiformatters.xml + * TODO: Fetch uiformatters.xml from the backend instead and use UIFormatter.placeholder + */ const currentYear = new Date().getFullYear(); const catalogNumberFormats = [ { value: 'CatalogNumber', label: `CatalogNumber (${currentYear}-######)` }, - { value: 'CatalogNumberAlphaNumByYear', label: `CatalogNumberAlphaNumByYear (${currentYear}-######)` }, + { + value: 'CatalogNumberAlphaNumByYear', + label: `CatalogNumberAlphaNumByYear (${currentYear}-######)`, + }, { value: 'CatalogNumberNumeric', label: 'CatalogNumberNumeric (#########)' }, { value: 'CatalogNumberString', label: 'None' }, ]; @@ -77,11 +82,11 @@ function generateTreeRankFields( enabled: RA, enforced: RA, inFullName: RA, - separator: string = ', ' + separator: string = ' ' ): RA { return rankNames.map( - (rankName, index) => { - return { + (rankName, index) => + ({ name: rankName.toLowerCase(), label: rankName, type: 'object', @@ -92,7 +97,7 @@ function generateTreeRankFields( type: 'boolean', default: index === 0 || enabled.includes(rankName), required: index === 0, - width: 1 + width: 1, }, { name: 'enforced', @@ -100,26 +105,25 @@ function generateTreeRankFields( type: 'boolean', default: index === 0 || enforced.includes(rankName), required: index === 0, - width: 1 + width: 1, }, { name: 'infullname', label: 'In Full Name', type: 'boolean', default: inFullName.includes(rankName), - width: 1 + width: 1, }, { name: 'fullnameseparator', label: 'Separator', type: 'text', default: separator, - width: 1 - } - ] - } as FieldConfig - } - ) + width: 1, + }, + ], + }) as FieldConfig + ); } export const resources: RA = [ @@ -213,11 +217,22 @@ export const resources: RA = [ type: 'object', // TODO: Rank fields should be generated from a .json file. fields: generateTreeRankFields( - ['Site', 'Building', 'Collection', 'Room', 'Aisle', 'Cabinet', 'Shelf', 'Box', 'Rack', 'Vial'], + [ + 'Site', + 'Building', + 'Collection', + 'Room', + 'Aisle', + 'Cabinet', + 'Shelf', + 'Box', + 'Rack', + 'Vial', + ], ['Site', 'Building', 'Collection', 'Room', 'Aisle', 'Cabinet'], [], [] - ) + ), }, // TODO: This should be name direction. Each rank should have configurable formats, too., { @@ -271,7 +286,7 @@ export const resources: RA = [ ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], [] - ) + ), }, { name: 'fullNameDirection', @@ -298,11 +313,43 @@ export const resources: RA = [ required: false, type: 'object', fields: generateTreeRankFields( - ['Life', 'Kingdom', 'Phylum', 'Subphylum', 'Class', 'Subclass', 'Superorder', 'Order', 'Family', 'Subfamily', 'Genus', 'Species', 'Subspecies'], - ['Life', 'Kingdom', 'Phylum', 'Class', 'Order', 'Family', 'Genus', 'Species'], - ['Life', 'Kingdom', 'Phylum', 'Class', 'Order', 'Family', 'Genus', 'Species'], + [ + 'Life', + 'Kingdom', + 'Phylum', + 'Subphylum', + 'Class', + 'Subclass', + 'Superorder', + 'Order', + 'Family', + 'Subfamily', + 'Genus', + 'Species', + 'Subspecies', + ], + [ + 'Life', + 'Kingdom', + 'Phylum', + 'Class', + 'Order', + 'Family', + 'Genus', + 'Species', + ], + [ + 'Life', + 'Kingdom', + 'Phylum', + 'Class', + 'Order', + 'Family', + 'Genus', + 'Species', + ], ['Genus', 'Species', 'Subspecies'] - ) + ), }, { name: 'fullNameDirection', From 197d733d7525f1929465b484d38833bb6c00910c Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 19 Jan 2026 14:14:07 -0600 Subject: [PATCH 28/52] Remove pre-load taxon tree option Add collapsible tree rank sections Add separator to Geography tree Add description to pre-load tree option --- .../lib/components/SetupTool/SetupForm.tsx | 9 ++++++- .../components/SetupTool/setupResources.ts | 25 +++++++++++++------ .../js_src/lib/localization/setupTool.ts | 9 ++++--- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index d15a45385f4..e34a791ba03 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -80,6 +80,7 @@ export function renderFormFieldFactory({ fields, passwordRepeat, width, + collapse, } = field; const fieldName = parentName === undefined ? name : `${parentName}.${name}`; @@ -205,7 +206,13 @@ export function renderFormFieldFactory({

    {label}

    - {fields ? renderFormFields(fields, fieldName) : null} + {collapse ? ( +
    + {fields ? renderFormFields(fields, fieldName) : null} +
    + ) : ( + fields ? renderFormFields(fields, fieldName) : null + )}
) : ( diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index dde2c3a17a6..fd6c7163ad9 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -40,6 +40,7 @@ export type FieldConfig = { }; readonly maxLength?: number; readonly width?: number; + readonly collapse?: boolean; }; // Discipline list from backend/context/app_resource.py @@ -215,6 +216,7 @@ export const resources: RA = [ label: setupToolText.treeRanks(), required: false, type: 'object', + collapse: true, // TODO: Rank fields should be generated from a .json file. fields: generateTreeRankFields( [ @@ -231,7 +233,8 @@ export const resources: RA = [ ], ['Site', 'Building', 'Collection', 'Room', 'Aisle', 'Cabinet'], [], - [] + [], + ' ' ), }, // TODO: This should be name direction. Each rank should have configurable formats, too., @@ -281,11 +284,13 @@ export const resources: RA = [ label: setupToolText.treeRanks(), required: false, type: 'object', + collapse: true, fields: generateTreeRankFields( ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], - [] + [], + ', ' ), }, { @@ -299,6 +304,7 @@ export const resources: RA = [ { name: 'preload', label: setupToolText.preloadTree(), + description: setupToolText.preloadTreeDescription(), type: 'boolean', }, ], @@ -312,6 +318,7 @@ export const resources: RA = [ label: setupToolText.treeRanks(), required: false, type: 'object', + collapse: true, fields: generateTreeRankFields( [ 'Life', @@ -348,7 +355,8 @@ export const resources: RA = [ 'Genus', 'Species', ], - ['Genus', 'Species', 'Subspecies'] + ['Genus', 'Species', 'Subspecies'], + ' ' ), }, { @@ -359,11 +367,12 @@ export const resources: RA = [ required: true, default: fullNameDirections[0].value.toString(), }, - { - name: 'preload', - label: setupToolText.preloadTree(), - type: 'boolean', - }, + // Pre-loading is disabled for now for taxon trees. + // { + // name: 'preload', + // label: setupToolText_preloadTree(), + // type: 'boolean', + // }, ], }, { diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index 0fdd17d452e..4f208906262 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -126,6 +126,12 @@ export const setupToolText = createDictionary({ fullNameDirection: { 'en-us': 'Full Name Direction', }, + preloadTree: { + 'en-us': 'Pre-load Tree' + }, + preloadTreeDescription: { + 'en-us': 'Download default records for this tree.' + }, // Storage Tree storageTree: { @@ -139,9 +145,6 @@ export const setupToolText = createDictionary({ taxonTree: { 'en-us': 'Taxon Tree', }, - preloadTree: { - 'en-us': 'Pre-load Tree' - }, // Division division: { From 14ea945ff1c24d1e3e92219f4e99488a173c4639 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 08:16:08 -0600 Subject: [PATCH 29/52] Fix: Update localization --- .../lib/components/SetupTool/setupResources.ts | 17 ++++++++++------- .../js_src/lib/localization/setupTool.ts | 10 +++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index fd6c7163ad9..225b95831e5 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -2,6 +2,7 @@ import type { LocalizedString } from 'typesafe-i18n'; import { formsText } from '../../localization/forms'; import { setupToolText } from '../../localization/setupTool'; +import { statsText } from '../../localization/stats'; import type { RA } from '../../utils/types'; // Default for max field length. @@ -367,17 +368,19 @@ export const resources: RA = [ required: true, default: fullNameDirections[0].value.toString(), }, - // Pre-loading is disabled for now for taxon trees. - // { - // name: 'preload', - // label: setupToolText_preloadTree(), - // type: 'boolean', - // }, + /* + * Pre-loading is disabled for now for taxon trees. + * { + * name: 'preload', + * label: setupToolText_preloadTree(), + * type: 'boolean', + * }, + */ ], }, { resourceName: 'collection', - label: setupToolText.collection(), + label: statsText.collection(), fields: [ { name: 'collectionName', diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index 4f208906262..65db8bb48f8 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -127,10 +127,10 @@ export const setupToolText = createDictionary({ 'en-us': 'Full Name Direction', }, preloadTree: { - 'en-us': 'Pre-load Tree' + 'en-us': 'Pre-load Tree', }, preloadTreeDescription: { - 'en-us': 'Download default records for this tree.' + 'en-us': 'Download default records for this tree.', }, // Storage Tree @@ -169,9 +169,6 @@ export const setupToolText = createDictionary({ }, // Collection - collection: { - 'en-us': 'Collection', - }, collectionName: { 'en-us': 'Collection Name', }, @@ -215,8 +212,7 @@ export const setupToolText = createDictionary({ 'en-us': 'Last Name', }, specifyUserLastNameDescription: { - 'en-us': - 'The last name of the agent associated with the account.', + 'en-us': 'The last name of the agent associated with the account.', }, taxonTreeSetUp: { From a689b4d569ad29285b28e4df862b8da125137c33 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 08:58:54 -0600 Subject: [PATCH 30/52] correct parent ranks when creating a new tree --- specifyweb/backend/trees/defaults.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py index 62d96ebbb3b..56a2b5200ed 100644 --- a/specifyweb/backend/trees/defaults.py +++ b/specifyweb/backend/trees/defaults.py @@ -63,7 +63,7 @@ def initialize_default_tree(tree_type: str, discipline_or_institution, tree_name tree_rank_model( treedef=tree_def, name=rank.get('name'), - title=rank.get('title') or rank.get('name').title(), + title=(rank.get('title') or rank.get('name').title()), rankid=int(rank.get('rank', rank_id)), isenforced=rank.get('enforced', True), isinfullname=rank.get('infullname', False), @@ -75,6 +75,18 @@ def initialize_default_tree(tree_type: str, discipline_or_institution, tree_name tree_rank_model.objects.bulk_create(treedefitems_bulk, ignore_conflicts=False) # Create a root node + created_items = list( + tree_rank_model.objects.filter(treedef=tree_def).order_by('rankid') + ) + + parent_item = None + for item in created_items: + item.parent = parent_item + parent_item = item + + tree_rank_model.objects.bulk_update(created_items, ['parent']) + + # Create a root node for non-taxon trees # New taxon trees are expected to be empty if tree_type != 'taxon': create_default_root(tree_def, tree_type) From 29e0916706cc44d56b198fae1e9fe7238de8163b Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 09:17:52 -0600 Subject: [PATCH 31/52] Update tree defaults --- .../lib/components/SetupTool/SetupForm.tsx | 4 +++- .../components/SetupTool/setupResources.ts | 20 +++++++++------- .../js_src/lib/localization/setupTool.ts | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index e34a791ba03..7fafdd31d61 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -87,6 +87,8 @@ export function renderFormFieldFactory({ const colSpan = width ? `col-span-${width}` : (type === 'object' ? 'col-span-4' : 'col-span-2'); + const verticalSpacing = (width && width < 2) ? '-mb-2' : 'mb-2' + const disciplineTypeValue = resources[currentStep].resourceName === 'discipline' ? getFormValue(formData, currentStep, 'type') @@ -97,7 +99,7 @@ export function renderFormFieldFactory({ (disciplineTypeValue === undefined || disciplineTypeValue === ''); return ( -
+
{type === 'boolean' ? (
diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 225b95831e5..5932faf25b8 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -95,7 +95,8 @@ function generateTreeRankFields( fields: [ { name: 'include', - label: 'Include', + label: setupToolText.include(), + description: setupToolText.includeDescription(), type: 'boolean', default: index === 0 || enabled.includes(rankName), required: index === 0, @@ -103,7 +104,8 @@ function generateTreeRankFields( }, { name: 'enforced', - label: 'Enforced', + label: setupToolText.enforced(), + description: setupToolText.enforcedDescription(), type: 'boolean', default: index === 0 || enforced.includes(rankName), required: index === 0, @@ -111,14 +113,16 @@ function generateTreeRankFields( }, { name: 'infullname', - label: 'In Full Name', + label: setupToolText.inFullName(), + description: setupToolText.inFullNameDescription(), type: 'boolean', default: inFullName.includes(rankName), width: 1, }, { name: 'fullnameseparator', - label: 'Separator', + label: setupToolText.fullNameSeparator(), + description: setupToolText.fullNameSeparatorDescription(), type: 'text', default: separator, width: 1, @@ -234,8 +238,8 @@ export const resources: RA = [ ], ['Site', 'Building', 'Collection', 'Room', 'Aisle', 'Cabinet'], [], - [], - ' ' + ['Room', 'Aisle', 'Cabinet'], + ', ' ), }, // TODO: This should be name direction. Each rank should have configurable formats, too., @@ -290,7 +294,7 @@ export const resources: RA = [ ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], - [], + ['Country', 'State'], ', ' ), }, @@ -356,7 +360,7 @@ export const resources: RA = [ 'Genus', 'Species', ], - ['Genus', 'Species', 'Subspecies'], + ['Genus', 'Species'], ' ' ), }, diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index 65db8bb48f8..b416b69a028 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -132,6 +132,30 @@ export const setupToolText = createDictionary({ preloadTreeDescription: { 'en-us': 'Download default records for this tree.', }, + include: { + 'en-us': 'Include', + }, + includeDescription: { + 'en-us': 'Include places the Level in the tree definition.', + }, + enforced: { + 'en-us': 'Enforced', + }, + enforcedDescription: { + 'en-us': 'Is Enforced ensures that the level can not be skipped when adding nodes lower down the tree.', + }, + inFullName: { + 'en-us': 'In Full Name', + }, + inFullNameDescription: { + 'en-us': 'Is in Full Name includes the level when building a full name expression, which can be queried and used in reports.', + }, + fullNameSeparator: { + 'en-us': 'Separator', + }, + fullNameSeparatorDescription: { + 'en-us': 'Separator refers to the character that separates the levels when displaying the full name.', + }, // Storage Tree storageTree: { From cdd1f4236bc5206a874a23639663c6a08fa80ce5 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 15:21:50 +0000 Subject: [PATCH 32/52] Lint code with ESLint and Prettier Triggered by 29e0916706cc44d56b198fae1e9fe7238de8163b on branch refs/heads/issue-7593 --- specifyweb/frontend/js_src/lib/localization/setupTool.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index b416b69a028..cdc8018a02d 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -142,19 +142,22 @@ export const setupToolText = createDictionary({ 'en-us': 'Enforced', }, enforcedDescription: { - 'en-us': 'Is Enforced ensures that the level can not be skipped when adding nodes lower down the tree.', + 'en-us': + 'Is Enforced ensures that the level can not be skipped when adding nodes lower down the tree.', }, inFullName: { 'en-us': 'In Full Name', }, inFullNameDescription: { - 'en-us': 'Is in Full Name includes the level when building a full name expression, which can be queried and used in reports.', + 'en-us': + 'Is in Full Name includes the level when building a full name expression, which can be queried and used in reports.', }, fullNameSeparator: { 'en-us': 'Separator', }, fullNameSeparatorDescription: { - 'en-us': 'Separator refers to the character that separates the levels when displaying the full name.', + 'en-us': + 'Separator refers to the character that separates the levels when displaying the full name.', }, // Storage Tree From aaf0ff1a88ad6a19ffa90b735dfa3cb9755e583e Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 09:34:11 -0600 Subject: [PATCH 33/52] Rename setup tool tree functions --- specifyweb/backend/setup_tool/api.py | 4 ++-- specifyweb/backend/setup_tool/setup_tasks.py | 8 ++++---- specifyweb/backend/setup_tool/tree_defaults.py | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/specifyweb/backend/setup_tool/api.py b/specifyweb/backend/setup_tool/api.py index dfbfe9e2cd7..c6d37e0ccef 100644 --- a/specifyweb/backend/setup_tool/api.py +++ b/specifyweb/backend/setup_tool/api.py @@ -17,7 +17,7 @@ from specifyweb.backend.setup_tool.prep_type_defaults import create_default_prep_types from specifyweb.backend.setup_tool.setup_tasks import setup_database_background, get_active_setup_task, get_last_setup_error, set_last_setup_error from specifyweb.celery_tasks import MissingWorkerError -from specifyweb.backend.setup_tool.tree_defaults import create_default_tree, update_tree_scoping +from specifyweb.backend.setup_tool.tree_defaults import start_default_tree_from_configuration, update_tree_scoping from specifyweb.specify.models import Institution, Discipline from specifyweb.backend.businessrules.uniqueness_rules import apply_default_uniqueness_rules from specifyweb.specify.management.commands.run_key_migration_functions import fix_cots @@ -407,7 +407,7 @@ def create_tree(name: str, data: dict) -> dict: if use_discipline and discipline is not None: kwargs['discipline'] = discipline - treedef = create_default_tree(name, kwargs, ranks, preload_tree) + treedef = start_default_tree_from_configuration(name, kwargs, ranks, preload_tree) # Set as the primary tree in the discipline if its the first one if use_discipline and discipline: diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py index ccfa1f8ac17..a0cf337c990 100644 --- a/specifyweb/backend/setup_tool/setup_tasks.py +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -8,7 +8,7 @@ from celery.result import AsyncResult from specifyweb.backend.setup_tool import api from specifyweb.backend.setup_tool.app_resource_defaults import create_app_resource_defaults -from specifyweb.backend.setup_tool.tree_defaults import preload_default_tree +from specifyweb.backend.setup_tool.tree_defaults import start_preload_default_tree from specifyweb.specify.management.commands.run_key_migration_functions import fix_schema_config from specifyweb.specify.models_utils.model_extras import PALEO_DISCIPLINES, GEOLOGY_DISCIPLINES from specifyweb.celery_tasks import is_worker_alive, MissingWorkerError @@ -148,11 +148,11 @@ def update_progress(): # Pre-load trees logger.info('Starting default tree downloads') if is_paleo_geo: - preload_default_tree('Geologictimeperiod', discipline_id, collection_id, chronostrat_treedef_id, specifyuser_id) + start_preload_default_tree('Geologictimeperiod', discipline_id, collection_id, chronostrat_treedef_id, specifyuser_id) if data['geographytreedef'].get('preload'): - preload_default_tree('Geography', discipline_id, collection_id, geography_treedef_id, specifyuser_id) + start_preload_default_tree('Geography', discipline_id, collection_id, geography_treedef_id, specifyuser_id) if data['taxontreedef'].get('preload'): - preload_default_tree('Taxon', discipline_id, collection_id, taxon_treedef_id, specifyuser_id) + start_preload_default_tree('Taxon', discipline_id, collection_id, taxon_treedef_id, specifyuser_id) update_progress() except Exception as e: diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 859f9a45320..f224a747f69 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -30,8 +30,8 @@ 'Geologictimeperiod': 'https://files.specifysoftware.org/treerows/geologictimeperiod.json', } -def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[bool]): - """Creates an initial empty tree. This should not be used outside of the initial database setup.""" +def start_default_tree_from_configuration(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[bool]): + """Starts the creation of an initial empty tree. This should not be used outside of the initial database setup.""" # Load all default ranks for this type of tree rank_data = load_json_from_file(DEFAULT_TREE_RANKS_FILES.get(tree_type)) if rank_data is None: @@ -76,8 +76,8 @@ def create_default_tree(tree_type: str, kwargs: dict, user_rank_cfg: dict, prelo return tree_def -def preload_default_tree(tree_type: str, discipline_id: Optional[int], collection_id: Optional[int], tree_def_id: int, specify_user_id: Optional[int]): - """Creates a populated default tree without user input.""" +def start_preload_default_tree(tree_type: str, discipline_id: Optional[int], collection_id: Optional[int], tree_def_id: int, specify_user_id: Optional[int]): + """Starts a populated default tree import without user input.""" try: # Tree download config: tree_discipline_name = tree_type.lower() From 4b593a5bc79924453a17c23ece209cff2f59c314 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 11:29:21 -0600 Subject: [PATCH 34/52] Update tree defaults --- .../js_src/lib/components/SetupTool/setupResources.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 5932faf25b8..11b86be9c0e 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -238,7 +238,7 @@ export const resources: RA = [ ], ['Site', 'Building', 'Collection', 'Room', 'Aisle', 'Cabinet'], [], - ['Room', 'Aisle', 'Cabinet'], + ['Building', 'Collection', 'Room', 'Aisle', 'Cabinet'], ', ' ), }, @@ -294,7 +294,7 @@ export const resources: RA = [ ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], - ['Country', 'State'], + ['Country', 'State', 'County'], ', ' ), }, From a45eb04bd2c434c3e5f5a20bd8e86fa6c771ff5d Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 13:45:32 -0600 Subject: [PATCH 35/52] Remove taxon tree configuration Disable preload checkbox if there is no tree to preload Fix tests --- .../backend/setup_tool/tree_defaults.py | 8 +- .../lib/components/SetupTool/SetupForm.tsx | 26 ++- .../js_src/lib/components/SetupTool/index.tsx | 41 ++-- .../components/SetupTool/setupResources.ts | 118 +++++----- .../lib/components/TreeView/CreateTree.tsx | 220 ++++++++++-------- 5 files changed, 233 insertions(+), 180 deletions(-) diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index f224a747f69..19220d32db2 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -33,7 +33,13 @@ def start_default_tree_from_configuration(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[bool]): """Starts the creation of an initial empty tree. This should not be used outside of the initial database setup.""" # Load all default ranks for this type of tree - rank_data = load_json_from_file(DEFAULT_TREE_RANKS_FILES.get(tree_type)) + if tree_type == 'Taxon': + discipline = kwargs.get('discipline') + if discipline: + taxon_tree_discipline = discipline.type + rank_data = load_json_from_file(Path(__file__).parent.parent.parent.parent / 'config' / taxon_tree_discipline / f'taxon_{taxon_tree_discipline}_tree.json') + else: + rank_data = load_json_from_file(DEFAULT_TREE_RANKS_FILES.get(tree_type)) if rank_data is None: raise Exception(f'Could not load default rank JSON file for tree type {tree_type}.') diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 7fafdd31d61..d72143862c6 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -11,6 +11,7 @@ import { type RA } from '../../utils/types'; import { H3 } from '../Atoms'; import { Input, Label, Select } from '../Atoms/Form'; import { MIN_PASSWORD_LENGTH } from '../Security/SetPassword'; +import type { TaxonFileDefaultList } from '../TreeView/CreateTree'; import type { FieldConfig, ResourceConfig } from './setupResources'; import { FIELD_MAX_LENGTH, resources } from './setupResources'; import type { ResourceFormData } from './types'; @@ -53,6 +54,7 @@ export function renderFormFieldFactory({ temporaryFormData, setTemporaryFormData, formRef, + treeOptions, }: { readonly formData: ResourceFormData; readonly currentStep: number; @@ -65,6 +67,7 @@ export function renderFormFieldFactory({ value: React.SetStateAction ) => void; readonly formRef: React.MutableRefObject; + readonly treeOptions?: TaxonFileDefaultList | undefined; }) { const renderFormField = ( field: FieldConfig, @@ -85,9 +88,9 @@ export function renderFormFieldFactory({ const fieldName = parentName === undefined ? name : `${parentName}.${name}`; - const colSpan = width ? `col-span-${width}` : (type === 'object' ? 'col-span-4' : 'col-span-2'); + const colSpan = (width !== undefined) ? `col-span-${width}` : (type === 'object' ? 'col-span-4' : 'col-span-2'); - const verticalSpacing = (width && width < 2) ? '-mb-2' : 'mb-2' + const verticalSpacing = (width !== undefined && width < 2) ? '-mb-2' : 'mb-2' const disciplineTypeValue = resources[currentStep].resourceName === 'discipline' @@ -98,15 +101,26 @@ export function renderFormFieldFactory({ fieldName === 'name' && (disciplineTypeValue === undefined || disciplineTypeValue === ''); + const taxonTreePreloadDisabled = + resources[currentStep].resourceName === 'taxonTreeDef' && + fieldName === 'preload' && + ( + Array.isArray(treeOptions) && + treeOptions.some( + (tree) => tree.discipline === getFormValue(formData, 3, 'type') + ) === false + ); + return (
{type === 'boolean' ? ( -
+
{label} - {collapse ? ( + {(collapse === true) ? (
{fields ? renderFormFields(fields, fieldName) : null}
@@ -233,7 +247,7 @@ export function renderFormFieldFactory({ ); }; - const renderFormFields = (fields: RA, parentName?: string) => ( + const renderFormFields = (fields: RA, parentName?: string): JSX.Element => (
{fields.map((field) => renderFormField(field, parentName))}
diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index 4bac0efe29d..d8d43748db1 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -8,8 +8,7 @@ import { setupToolText } from '../../localization/setupTool'; import { ajax } from '../../utils/ajax'; import { Http } from '../../utils/ajax/definitions'; import { type RA, localized } from '../../utils/types'; -import { Container, H2, H3 } from '../Atoms'; -import { Progress } from '../Atoms'; +import { Container, H2, H3, Progress } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Form } from '../Atoms/Form'; import { dialogIcons } from '../Atoms/Icons'; @@ -17,6 +16,8 @@ import { Link } from '../Atoms/Link'; import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; import { loadingBar } from '../Molecules'; +import type { TaxonFileDefaultList } from '../TreeView/CreateTree'; +import { fetchDefaultTrees } from '../TreeView/CreateTree'; import { checkFormCondition, renderFormFieldFactory } from './SetupForm'; import { SetupOverview } from './SetupOverview'; import type { FieldConfig, ResourceConfig } from './setupResources'; @@ -29,6 +30,8 @@ import type { } from './types'; import { flattenAllResources } from './utils'; +const SETUP_POLLING_INTERVAL = 3000; + export const stepOrder: RA = [ 'institution', 'storageTreeDef', @@ -60,23 +63,22 @@ function findNextStep( return currentStep; } -function useFormDefaults( +function applyFormDefaults( resource: ResourceConfig, setFormData: (data: ResourceFormData) => void, currentStep: number ): void { const resourceName = resources[currentStep].resourceName; const defaultFormData: ResourceFormData = {}; - const applyFieldDefaults = (field: FieldConfig, parentName?: string) => { + const applyFieldDefaults = (field: FieldConfig, parentName?: string): void => { const fieldName = parentName === undefined ? field.name : `${parentName}.${field.name}`; - console.log(fieldName); if (field.type === 'object' && field.fields !== undefined) field.fields.forEach((field) => applyFieldDefaults(field, fieldName)); if (field.default !== undefined) defaultFormData[fieldName] = field.default; }; resource.fields.forEach((field) => applyFieldDefaults(field)); - setFormData((previous: any) => ({ + setFormData((previous: ResourceFormData) => ({ ...previous, [resourceName]: { ...defaultFormData, @@ -106,17 +108,29 @@ export function SetupTool({ const [currentStep, setCurrentStep] = React.useState(0); React.useEffect(() => { - useFormDefaults(resources[currentStep], setFormData, currentStep); + applyFormDefaults(resources[currentStep], setFormData, currentStep); }, [currentStep]); const [saveBlocked, setSaveBlocked] = React.useState(false); React.useEffect(() => { const formValid = formRef.current?.checkValidity(); - setSaveBlocked(!formValid); + setSaveBlocked(formValid !== true); }, [formData, temporaryFormData, currentStep]); const SubmitComponent = saveBlocked ? Submit.Danger : Submit.Save; + // Fetch list of available default trees. + const [treeOptions, setTreeOptions] = React.useState< + TaxonFileDefaultList | undefined + >(undefined); + React.useEffect(() => { + fetchDefaultTrees() + .then((data) => setTreeOptions(data)) + .catch((error) => { + console.error('Failed to fetch tree options:', error); + }); + }, []); + // Keep track of the last backend error. const [setupError, setSetupError] = React.useState( setupProgress.last_error @@ -147,7 +161,7 @@ export function SetupTool({ console.error('Failed to fetch setup progress:', error); return undefined; }), - 3000 + SETUP_POLLING_INTERVAL ); return () => clearInterval(interval); @@ -155,7 +169,7 @@ export function SetupTool({ const loading = React.useContext(LoadingContext); - const startSetup = async (data: ResourceFormData): Promise => + const startSetup = async (data: ResourceFormData): Promise => ajax('/setup_tool/setup_database/create/', { method: 'POST', headers: { @@ -185,7 +199,7 @@ export function SetupTool({ name: string, newValue: LocalizedString | boolean ): void => { - setFormData((previous) => { + setFormData((previous: ResourceFormData) => { const resourceName = resources[currentStep].resourceName; const previousResourceData = previous[resourceName]; const updates: Record = { @@ -220,7 +234,7 @@ export function SetupTool({ loading( startSetup(formData) .then((data) => { - setSetupProgress(data.setup_progress as SetupProgress); + setSetupProgress(data.setup_progress); setInProgress(true); }) .catch((error) => { @@ -245,6 +259,7 @@ export function SetupTool({ temporaryFormData, setTemporaryFormData, formRef, + treeOptions, }); const id = useId('setup-tool'); @@ -253,7 +268,7 @@ export function SetupTool({
- +

{setupToolText.guidedSetup()}

diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 11b86be9c0e..57d9d302fc7 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -318,68 +318,66 @@ export const resources: RA = [ resourceName: 'taxonTreeDef', label: setupToolText.taxonTree(), fields: [ + // { + // name: 'ranks', + // label: setupToolText.treeRanks(), + // required: false, + // type: 'object', + // collapse: true, + // fields: generateTreeRankFields( + // [ + // 'Life', + // 'Kingdom', + // 'Phylum', + // 'Subphylum', + // 'Class', + // 'Subclass', + // 'Superorder', + // 'Order', + // 'Family', + // 'Subfamily', + // 'Genus', + // 'Species', + // 'Subspecies', + // ], + // [ + // 'Life', + // 'Kingdom', + // 'Phylum', + // 'Class', + // 'Order', + // 'Family', + // 'Genus', + // 'Species', + // ], + // [ + // 'Life', + // 'Kingdom', + // 'Phylum', + // 'Class', + // 'Order', + // 'Family', + // 'Genus', + // 'Species', + // ], + // ['Genus', 'Species'], + // ' ' + // ), + // }, + // { + // name: 'fullNameDirection', + // label: setupToolText.fullNameDirection(), + // type: 'select', + // options: fullNameDirections, + // required: true, + // default: fullNameDirections[0].value.toString(), + // }, { - name: 'ranks', - label: setupToolText.treeRanks(), - required: false, - type: 'object', - collapse: true, - fields: generateTreeRankFields( - [ - 'Life', - 'Kingdom', - 'Phylum', - 'Subphylum', - 'Class', - 'Subclass', - 'Superorder', - 'Order', - 'Family', - 'Subfamily', - 'Genus', - 'Species', - 'Subspecies', - ], - [ - 'Life', - 'Kingdom', - 'Phylum', - 'Class', - 'Order', - 'Family', - 'Genus', - 'Species', - ], - [ - 'Life', - 'Kingdom', - 'Phylum', - 'Class', - 'Order', - 'Family', - 'Genus', - 'Species', - ], - ['Genus', 'Species'], - ' ' - ), - }, - { - name: 'fullNameDirection', - label: setupToolText.fullNameDirection(), - type: 'select', - options: fullNameDirections, - required: true, - default: fullNameDirections[0].value.toString(), + name: 'preload', + label: setupToolText.preloadTree(), + description: setupToolText.preloadTreeDescription(), + type: 'boolean', }, - /* - * Pre-loading is disabled for now for taxon trees. - * { - * name: 'preload', - * label: setupToolText_preloadTree(), - * type: 'boolean', - * }, - */ ], }, { diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx index 4ec3e5927ef..04f17562e02 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx @@ -28,7 +28,7 @@ import { userInformation } from '../InitialContext/userInformation'; import { Dialog } from '../Molecules/Dialog'; import { defaultTreeDefs } from './defaults'; -type TaxonFileDefaultDefinition = { +export type TaxonFileDefaultDefinition = { readonly discipline: string; readonly title: string; readonly coverage: string; @@ -39,15 +39,28 @@ type TaxonFileDefaultDefinition = { readonly rows: number; readonly description: string; }; -type TaxonFileDefaultList = RA; +export type TaxonFileDefaultList = RA; type TreeCreationInfo = { readonly message: string; readonly task_id?: string; -} +}; type TreeCreationProgressInfo = { readonly taskstatus: string; readonly taskprogress: any; readonly taskid: string; +}; + +export async function fetchDefaultTrees(): Promise { + const response = await fetch( + 'https://files.specifysoftware.org/taxonfiles/taxonfiles.json' + ); + if (!response.ok) { + throw new Error( + `Failed to fetch default trees: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + return data as TaxonFileDefaultList; } export function CreateTree< @@ -64,8 +77,11 @@ export function CreateTree< const loading = React.useContext(LoadingContext); const [isActive, setIsActive] = React.useState(0); - const [isTreeCreationStarted, setIsTreeCreationStarted] = React.useState(false); - const [treeCreationTaskId, setTreeCreationTaskId] = React.useState(undefined); + const [isTreeCreationStarted, setIsTreeCreationStarted] = + React.useState(false); + const [treeCreationTaskId, setTreeCreationTaskId] = React.useState< + string | undefined + >(undefined); const [selectedResource, setSelectedResource] = React.useState< SpecifyResource | undefined @@ -74,7 +90,9 @@ export function CreateTree< const connectedCollection = getSystemInfo().collection; // Start default tree creation - const handleClick = async (resource: TaxonFileDefaultDefinition): Promise => { + const handleClick = async ( + resource: TaxonFileDefaultDefinition + ): Promise => { setIsTreeCreationStarted(true); return ajax('/trees/create_default_tree/', { method: 'POST', @@ -93,7 +111,10 @@ export function CreateTree< console.log(`${resource.title} tree created successfully:`, data); } else if (status === Http.ACCEPTED) { // Tree is being created in the background. - console.log(`${resource.title} tree creation started successfully:`, data); + console.log( + `${resource.title} tree creation started successfully:`, + data + ); setTreeCreationTaskId(data.task_id); } }) @@ -101,7 +122,7 @@ export function CreateTree< console.error(`Request failed for ${resource.file}:`, error); throw error; }); - } + }; const handleClickEmptyTree = ( resource: DeepPartial> @@ -143,18 +164,13 @@ export function CreateTree<
{ - loading( - handleClick(resource).catch(console.error) - )} - } + handleClick={(resource) => { + loading(handleClick(resource).catch(console.error)); + }} />
- +
<> @@ -191,9 +207,7 @@ export function CreateTree< ); } -export function ImportTree< - SCHEMA extends AnyTree, ->({ +export function ImportTree({ tableName, treeDefId, }: { @@ -202,12 +216,17 @@ export function ImportTree< }): JSX.Element { const loading = React.useContext(LoadingContext); const [isActive, setIsActive] = React.useState(0); - const [isTreeCreationStarted, setIsTreeCreationStarted] = React.useState(false); - const [treeCreationTaskId, setTreeCreationTaskId] = React.useState(undefined); + const [isTreeCreationStarted, setIsTreeCreationStarted] = + React.useState(false); + const [treeCreationTaskId, setTreeCreationTaskId] = React.useState< + string | undefined + >(undefined); const connectedCollection = getSystemInfo().collection; - const handleClick = async (resource: TaxonFileDefaultDefinition): Promise => { + const handleClick = async ( + resource: TaxonFileDefaultDefinition + ): Promise => { setIsTreeCreationStarted(true); return ajax('/trees/create_default_tree/', { method: 'POST', @@ -219,7 +238,7 @@ export function ImportTree< disciplineName: resource.discipline, rowCount: resource.rows, treeName: resource.title, - treeDefId: treeDefId + treeDefId: treeDefId, }, }) .then(({ data, status }) => { @@ -227,7 +246,10 @@ export function ImportTree< console.log(`${resource.title} tree created successfully:`, data); } else if (status === Http.ACCEPTED) { // Tree is being created in the background. - console.log(`${resource.title} tree creation started successfully:`, data); + console.log( + `${resource.title} tree creation started successfully:`, + data + ); setTreeCreationTaskId(data.task_id); } }) @@ -235,7 +257,7 @@ export function ImportTree< console.error(`Request failed for ${resource.file}:`, error); throw error; }); - } + }; return ( <> @@ -260,12 +282,9 @@ export function ImportTree< >
{ - loading( - handleClick(resource).catch(console.error) - )} - } + handleClick={(resource) => { + loading(handleClick(resource).catch(console.error)); + }} />
<> @@ -293,20 +312,22 @@ export function ImportTree< function EmptyTreeList({ handleClick, }: { - readonly handleClick: (resource: DeepPartial>) => void; + readonly handleClick: ( + resource: DeepPartial> + ) => void; }): JSX.Element { - return
    -

    {treeText.emptyTrees()}

    - {defaultTreeDefs.map((resource, index) => ( -
  • - handleClick(resource)} - > - {localized(resource.name)} - -
  • - ))} -
+ return ( +
    +

    {treeText.emptyTrees()}

    + {defaultTreeDefs.map((resource, index) => ( +
  • + handleClick(resource)}> + {localized(resource.name)} + +
  • + ))} +
+ ); } function PopulatedTreeList({ @@ -320,36 +341,33 @@ function PopulatedTreeList({ // Fetch list of available default trees. React.useEffect(() => { - fetch('https://files.specifysoftware.org/taxonfiles/taxonfiles.json') - .then(async (response) => response.json()) - .then((data: TaxonFileDefaultList) => { - setTreeOptions(data); - }) + fetchDefaultTrees() + .then((data) => setTreeOptions(data)) .catch((error) => { console.error('Failed to fetch tree options:', error); }); }, []); - return
    -

    {treeText.populatedTrees()}

    - {treeOptions === undefined - ? undefined - : treeOptions.map((resource, index) => ( -
  • - handleClick(resource)} - > - {localized(resource.title)} - -
    - {resource.description} -
    -
    - {`Source: ${resource.src}`} -
    -
  • - ))} -
+ return ( +
    +

    {treeText.populatedTrees()}

    + {treeOptions === undefined + ? undefined + : treeOptions.map((resource, index) => ( +
  • + handleClick(resource)}> + {localized(resource.title)} + +
    + {resource.description} +
    +
    + {`Source: ${resource.src}`} +
    +
  • + ))} +
+ ); } export function TreeCreationProgressDialog({ @@ -366,40 +384,38 @@ export function TreeCreationProgressDialog({ const [progressTotal, setProgressTotal] = React.useState(1); const handleStop = async (): Promise => { - ping( - `/trees/create_default_tree/abort/${taskId}/`, - { - method: 'POST', - body: {}, + ping(`/trees/create_default_tree/abort/${taskId}/`, { + method: 'POST', + body: {}, + }).then((status) => { + if (status === Http.NO_CONTENT) { + onStopped(); } - ) - .then((status) => { - if (status === Http.NO_CONTENT) { - onStopped(); - } - }) - } + }); + }; // Poll for tree creation progress React.useEffect(() => { const interval = setInterval( async () => - ajax(`/trees/create_default_tree/status/${taskId}/`, { - method: 'GET', - headers: { Accept: 'application/json' }, - errorMode: 'silent', - }) - .then(({ data }) => { - if (data.taskstatus === 'RUNNING') { - setProgress(data.taskprogress.current ?? 0); - setProgressTotal(data.taskprogress.total ?? 1); - } else if (data.taskstatus === 'FAILURE') { - onStopped(); - throw data.taskprogress; - } else if (data.taskstatus === 'SUCCESS') { - globalThis.location.reload(); - } - }), + ajax( + `/trees/create_default_tree/status/${taskId}/`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + errorMode: 'silent', + } + ).then(({ data }) => { + if (data.taskstatus === 'RUNNING') { + setProgress(data.taskprogress.current ?? 0); + setProgressTotal(data.taskprogress.total ?? 1); + } else if (data.taskstatus === 'FAILURE') { + onStopped(); + throw data.taskprogress; + } else if (data.taskstatus === 'SUCCESS') { + globalThis.location.reload(); + } + }), 5000 ); return () => clearInterval(interval); @@ -409,7 +425,11 @@ export function TreeCreationProgressDialog({ - {loading(handleStop())}}> + { + loading(handleStop()); + }} + > {commonText.cancel()} @@ -432,4 +452,4 @@ export function TreeCreationProgressDialog({ {treeText.defaultTreeCreationStartedDescription()} ); -} \ No newline at end of file +} From acc6f838509d13cad623c5bb49f3a6d0f824de2b Mon Sep 17 00:00:00 2001 From: alesan99 Date: Fri, 23 Jan 2026 19:49:42 +0000 Subject: [PATCH 36/52] Lint code with ESLint and Prettier Triggered by a45eb04bd2c434c3e5f5a20bd8e86fa6c771ff5d on branch refs/heads/issue-7593 --- .../lib/components/SetupTool/SetupForm.tsx | 6 +- .../js_src/lib/components/SetupTool/index.tsx | 17 ++- .../components/SetupTool/setupResources.ts | 110 +++++++++--------- .../lib/components/TreeView/CreateTree.tsx | 4 +- 4 files changed, 74 insertions(+), 63 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index d72143862c6..56337dd511f 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -88,7 +88,7 @@ export function renderFormFieldFactory({ const fieldName = parentName === undefined ? name : `${parentName}.${name}`; - const colSpan = (width !== undefined) ? `col-span-${width}` : (type === 'object' ? 'col-span-4' : 'col-span-2'); + const colSpan = (width === undefined) ? (type === 'object' ? 'col-span-4' : 'col-span-2') : `col-span-${width}`; const verticalSpacing = (width !== undefined && width < 2) ? '-mb-2' : 'mb-2' @@ -106,9 +106,9 @@ export function renderFormFieldFactory({ fieldName === 'preload' && ( Array.isArray(treeOptions) && - treeOptions.some( + !treeOptions.some( (tree) => tree.discipline === getFormValue(formData, 3, 'type') - ) === false + ) ); return ( diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index d8d43748db1..8783e42a74e 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -70,7 +70,10 @@ function applyFormDefaults( ): void { const resourceName = resources[currentStep].resourceName; const defaultFormData: ResourceFormData = {}; - const applyFieldDefaults = (field: FieldConfig, parentName?: string): void => { + const applyFieldDefaults = ( + field: FieldConfig, + parentName?: string + ): void => { const fieldName = parentName === undefined ? field.name : `${parentName}.${field.name}`; if (field.type === 'object' && field.fields !== undefined) @@ -137,7 +140,9 @@ export function SetupTool({ ); // Is the database currrently being created? - const [inProgress, setInProgress] = React.useState(setupProgress.busy); + const [inProgress, setInProgress] = React.useState( + setupProgress.busy + ); const nextIncompleteStep = stepOrder.findIndex( (resourceName) => !setupProgress.resources[resourceName] ); @@ -212,7 +217,7 @@ export function SetupTool({ (option) => option.value === newValue ); updates.name = matchingType - ? matchingType.label ?? String(matchingType.value) + ? (matchingType.label ?? String(matchingType.value)) : ''; } @@ -268,7 +273,11 @@ export function SetupTool({
- +

{setupToolText.guidedSetup()}

diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 57d9d302fc7..9d3d46a6706 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -318,60 +318,62 @@ export const resources: RA = [ resourceName: 'taxonTreeDef', label: setupToolText.taxonTree(), fields: [ - // { - // name: 'ranks', - // label: setupToolText.treeRanks(), - // required: false, - // type: 'object', - // collapse: true, - // fields: generateTreeRankFields( - // [ - // 'Life', - // 'Kingdom', - // 'Phylum', - // 'Subphylum', - // 'Class', - // 'Subclass', - // 'Superorder', - // 'Order', - // 'Family', - // 'Subfamily', - // 'Genus', - // 'Species', - // 'Subspecies', - // ], - // [ - // 'Life', - // 'Kingdom', - // 'Phylum', - // 'Class', - // 'Order', - // 'Family', - // 'Genus', - // 'Species', - // ], - // [ - // 'Life', - // 'Kingdom', - // 'Phylum', - // 'Class', - // 'Order', - // 'Family', - // 'Genus', - // 'Species', - // ], - // ['Genus', 'Species'], - // ' ' - // ), - // }, - // { - // name: 'fullNameDirection', - // label: setupToolText.fullNameDirection(), - // type: 'select', - // options: fullNameDirections, - // required: true, - // default: fullNameDirections[0].value.toString(), - // }, + /* + * { + * name: 'ranks', + * label: setupToolText.treeRanks(), + * required: false, + * type: 'object', + * collapse: true, + * fields: generateTreeRankFields( + * [ + * 'Life', + * 'Kingdom', + * 'Phylum', + * 'Subphylum', + * 'Class', + * 'Subclass', + * 'Superorder', + * 'Order', + * 'Family', + * 'Subfamily', + * 'Genus', + * 'Species', + * 'Subspecies', + * ], + * [ + * 'Life', + * 'Kingdom', + * 'Phylum', + * 'Class', + * 'Order', + * 'Family', + * 'Genus', + * 'Species', + * ], + * [ + * 'Life', + * 'Kingdom', + * 'Phylum', + * 'Class', + * 'Order', + * 'Family', + * 'Genus', + * 'Species', + * ], + * ['Genus', 'Species'], + * ' ' + * ), + * }, + * { + * name: 'fullNameDirection', + * label: setupToolText.fullNameDirection(), + * type: 'select', + * options: fullNameDirections, + * required: true, + * default: fullNameDirections[0].value.toString(), + * }, + */ { name: 'preload', label: setupToolText.preloadTree(), diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx index 04f17562e02..c17108caa50 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx @@ -212,7 +212,7 @@ export function ImportTree({ treeDefId, }: { readonly tableName: SCHEMA['tableName']; - readonly treeDefId: Number; + readonly treeDefId: number; }): JSX.Element { const loading = React.useContext(LoadingContext); const [isActive, setIsActive] = React.useState(0); @@ -238,7 +238,7 @@ export function ImportTree({ disciplineName: resource.discipline, rowCount: resource.rows, treeName: resource.title, - treeDefId: treeDefId, + treeDefId, }, }) .then(({ data, status }) => { From 16886058b55cb3f930598ce859b67d0964e22ed6 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 08:23:46 -0600 Subject: [PATCH 37/52] WIP use table for tree rank config --- .../lib/components/SetupTool/SetupForm.tsx | 130 +++++++++++++----- 1 file changed, 93 insertions(+), 37 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 56337dd511f..1d262f2a183 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -69,9 +69,22 @@ export function renderFormFieldFactory({ readonly formRef: React.MutableRefObject; readonly treeOptions?: TaxonFileDefaultList | undefined; }) { + const [darkTheme, setDarkTheme] = React.useState( + window.matchMedia('(prefers-color-scheme: dark)').matches + ); + + React.useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + setDarkTheme(e.matches); + }; + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); const renderFormField = ( field: FieldConfig, - parentName?: string + parentName?: string, + suppressLabel: boolean = false, ): JSX.Element => { const { name, @@ -128,7 +141,7 @@ export function renderFormFieldFactory({ handleChange(fieldName, isChecked) } /> - {label} + {!suppressLabel && label}
) : type === 'select' && Array.isArray(options) ? ( @@ -136,32 +149,34 @@ export function renderFormFieldFactory({ className="mb-4" key={`${resources[currentStep].resourceName}.${fieldName}`} > - - {label} - handleChange(fieldName, value)} + > + + {options.map((option) => ( + - {options.map((option) => ( - - ))} - - + ))} +
) : type === 'password' ? ( <> - {label} + {!suppressLabel && label} - {passwordRepeat.label} + {!suppressLabel && passwordRepeat.label} {label} - {(collapse === true) ? ( -
- {fields ? renderFormFields(fields, fieldName) : null} -
- ) : ( - fields ? renderFormFields(fields, fieldName) : null - )} + {fields ? renderFormFields(fields, fieldName, collapse === true) : null}
) : ( - {label} + {!suppressLabel && label} , parentName?: string): JSX.Element => ( -
- {fields.map((field) => renderFormField(field, parentName))} -
- ); + const renderFormFields = (fields: RA, parentName?: string, table: boolean = false): JSX.Element => { + if (table && fields.length > 0 && fields[0].fields) { + return ( +
+ + + + + {fields[0].fields!.map((subField) => ( + + ))} + + + + {fields.map((field, index) => ( + + + {field.fields!.map((subField) => ( + + ))} + + ))} + +
+ Rank + + {subField.label} +
+ {field.label} + + {renderFormField( + subField, + parentName === undefined ? field.name : `${parentName}.${field.name}` + )} +
+
+ ); + } + return ( +
+ {fields.map((field) => renderFormField(field, parentName))} +
+ ); + }; return { renderFormField, renderFormFields }; } From 353b894a076964d1020353461bf5f35baeeaf55a Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 09:54:12 -0600 Subject: [PATCH 38/52] WIP add taxon tree selector --- specifyweb/backend/setup_tool/api.py | 1 + specifyweb/backend/setup_tool/setup_tasks.py | 4 +- .../backend/setup_tool/tree_defaults.py | 28 +++----- .../lib/components/SetupTool/SetupForm.tsx | 69 ++++++++++++++----- .../js_src/lib/components/SetupTool/index.tsx | 4 +- .../components/SetupTool/setupResources.ts | 11 ++- .../lib/components/TreeView/CreateTree.tsx | 4 +- .../js_src/lib/localization/setupTool.ts | 6 ++ .../frontend/js_src/lib/localization/tree.ts | 3 + 9 files changed, 87 insertions(+), 43 deletions(-) diff --git a/specifyweb/backend/setup_tool/api.py b/specifyweb/backend/setup_tool/api.py index c6d37e0ccef..80295c23e35 100644 --- a/specifyweb/backend/setup_tool/api.py +++ b/specifyweb/backend/setup_tool/api.py @@ -398,6 +398,7 @@ def create_tree(name: str, data: dict) -> dict: # Pre-load Default Tree preload_tree = data.pop('preload', None) + preload_tree_file = data.pop('preloadFile', None) try: kwargs = {} diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py index a0cf337c990..f9b1ad6a86c 100644 --- a/specifyweb/backend/setup_tool/setup_tasks.py +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -150,9 +150,9 @@ def update_progress(): if is_paleo_geo: start_preload_default_tree('Geologictimeperiod', discipline_id, collection_id, chronostrat_treedef_id, specifyuser_id) if data['geographytreedef'].get('preload'): - start_preload_default_tree('Geography', discipline_id, collection_id, geography_treedef_id, specifyuser_id) + start_preload_default_tree('Geography', discipline_id, collection_id, geography_treedef_id, specifyuser_id, data['geographytreedef'].get('preloadfile')) if data['taxontreedef'].get('preload'): - start_preload_default_tree('Taxon', discipline_id, collection_id, taxon_treedef_id, specifyuser_id) + start_preload_default_tree('Taxon', discipline_id, collection_id, taxon_treedef_id, specifyuser_id, data['taxontreedef'].get('preloadfile')) update_progress() except Exception as e: diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 19220d32db2..00ae354fa6d 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -82,34 +82,24 @@ def start_default_tree_from_configuration(tree_type: str, kwargs: dict, user_ran return tree_def -def start_preload_default_tree(tree_type: str, discipline_id: Optional[int], collection_id: Optional[int], tree_def_id: int, specify_user_id: Optional[int]): +def start_preload_default_tree(tree_type: str, discipline_id: Optional[int], collection_id: Optional[int], tree_def_id: int, specify_user_id: Optional[int], preload_file = None): """Starts a populated default tree import without user input.""" try: # Tree download config: tree_discipline_name = tree_type.lower() tree_name = tree_type.title() + row_count = 1000000 # dummy value, only to allow progress checking # Tree file urls url = DEFAULT_TREE_URLS.get(tree_type) mapping_url = DEFAULT_TREE_MAPPING_URLS.get(tree_type) - if tree_type.lower() == 'taxon': - discipline = Discipline.objects.filter(pk=discipline_id).first() - tree_discipline_name = discipline.type - - # Retrieve taxon tree list to find an appropriate one. + if tree_type.lower() == 'taxon' and preload_file is not None: # Schema described in CreateTree.tsx - logger.debug(f'Fetching default taxon list from {DEFAULT_TAXON_TREE_LIST_URL}') - resp = requests.get(DEFAULT_TAXON_TREE_LIST_URL) - resp.raise_for_status() - taxon_tree_list = resp.json() - - for tree in taxon_tree_list: - if tree.get('discipline') == tree_discipline_name: - logger.debug(f'Found matching default taxon url for {tree_discipline_name}') - url = tree.get('file') - mapping_url = tree.get('mappingFile') - tree_name = tree.get('title') - break + logger.debug(f'Using tree configuration provided for taxon tree {tree_discipline_name}') + url = preload_file.get('file') + mapping_url = preload_file.get('mappingFile', preload_file.get('mappingfile')) + tree_name = preload_file.get('title', tree_name) + row_count = preload_file.get('rows', row_count) if not url or not mapping_url: logger.warning(f'Can\'t preload tree, no default tree URLs for {tree_discipline_name} tree.') @@ -121,7 +111,7 @@ def start_preload_default_tree(tree_type: str, discipline_id: Optional[int], col task_id = str(uuid4()) create_default_tree_task.apply_async( - args=[url, discipline_id, tree_discipline_name, collection_id, specify_user_id, tree_cfg, 1000000, tree_name, tree_def_id], + args=[url, discipline_id, tree_discipline_name, collection_id, specify_user_id, tree_cfg, row_count, tree_name, tree_def_id], task_id=f"create_default_tree_{tree_type}_{task_id}", taskid=task_id ) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 56337dd511f..2e2fd6112e6 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -10,11 +10,17 @@ import { userText } from '../../localization/user'; import { type RA } from '../../utils/types'; import { H3 } from '../Atoms'; import { Input, Label, Select } from '../Atoms/Form'; +import { Button } from '../Atoms/Button'; import { MIN_PASSWORD_LENGTH } from '../Security/SetPassword'; -import type { TaxonFileDefaultList } from '../TreeView/CreateTree'; import type { FieldConfig, ResourceConfig } from './setupResources'; import { FIELD_MAX_LENGTH, resources } from './setupResources'; import type { ResourceFormData } from './types'; +import { PopulatedTreeList } from '../TreeView/CreateTree'; +import type { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { treeText } from '../../localization/tree'; +import { Dialog } from '../Molecules/Dialog'; +import { setupToolText } from '../../localization/setupTool'; function getFormValue( formData: ResourceFormData, @@ -54,21 +60,21 @@ export function renderFormFieldFactory({ temporaryFormData, setTemporaryFormData, formRef, - treeOptions, }: { readonly formData: ResourceFormData; readonly currentStep: number; readonly handleChange: ( name: string, - newValue: LocalizedString | boolean + newValue: LocalizedString | boolean | TaxonFileDefaultDefinition ) => void; readonly temporaryFormData: ResourceFormData; readonly setTemporaryFormData: ( value: React.SetStateAction ) => void; readonly formRef: React.MutableRefObject; - readonly treeOptions?: TaxonFileDefaultList | undefined; }) { + const [isTreeDialogOpen, handleTreeDialogOpen, handleTreeDialogClose] = useBooleanState(false); + const renderFormField = ( field: FieldConfig, parentName?: string @@ -101,16 +107,6 @@ export function renderFormFieldFactory({ fieldName === 'name' && (disciplineTypeValue === undefined || disciplineTypeValue === ''); - const taxonTreePreloadDisabled = - resources[currentStep].resourceName === 'taxonTreeDef' && - fieldName === 'preload' && - ( - Array.isArray(treeOptions) && - !treeOptions.some( - (tree) => tree.discipline === getFormValue(formData, 3, 'type') - ) - ); - return (
{type === 'boolean' ? ( @@ -119,8 +115,7 @@ export function renderFormFieldFactory({ + ) : type === 'tree' ? ( + // Taxon tree selection + + {label} + + {setupToolText.selectATree()} + + {(() => { + // Display the selected tree + const selectedTree = getFormValue(formData, currentStep, fieldName); + if (selectedTree && typeof selectedTree === 'object') { + const tree = selectedTree as TaxonFileDefaultDefinition; + return ( +
+
{commonText.selected()}
+
+
{tree.title}
+
{tree.description}
+
{`${treeText.source()}: ${tree.src}`}
+
+
+ ); + } + return null; + })()} + {isTreeDialogOpen ? ( + { + handleChange(fieldName, resource); + handleTreeDialogClose(); + } + } + /> + ) : null} +
) : ( {label} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index 8783e42a74e..ae301baffa6 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -29,6 +29,7 @@ import type { SetupResponse, } from './types'; import { flattenAllResources } from './utils'; +import type { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; const SETUP_POLLING_INTERVAL = 3000; @@ -202,7 +203,7 @@ export function SetupTool({ const handleChange = ( name: string, - newValue: LocalizedString | boolean + newValue: LocalizedString | boolean | TaxonFileDefaultDefinition ): void => { setFormData((previous: ResourceFormData) => { const resourceName = resources[currentStep].resourceName; @@ -264,7 +265,6 @@ export function SetupTool({ temporaryFormData, setTemporaryFormData, formRef, - treeOptions, }); const id = useId('setup-tool'); diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 9d3d46a6706..3738dc7e1da 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -1,5 +1,6 @@ import type { LocalizedString } from 'typesafe-i18n'; +import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; import { setupToolText } from '../../localization/setupTool'; import { statsText } from '../../localization/stats'; @@ -28,7 +29,7 @@ type Option = { export type FieldConfig = { readonly name: string; readonly label: string; - readonly type?: 'boolean' | 'object' | 'password' | 'select' | 'text'; + readonly type?: 'boolean' | 'object' | 'password' | 'select' | 'text' | 'tree'; readonly required?: boolean; readonly default?: boolean | number | string; readonly description?: string; @@ -71,7 +72,7 @@ const catalogNumberFormats = [ label: `CatalogNumberAlphaNumByYear (${currentYear}-######)`, }, { value: 'CatalogNumberNumeric', label: 'CatalogNumberNumeric (#########)' }, - { value: 'CatalogNumberString', label: 'None' }, + { value: 'CatalogNumberString', label: commonText.none() }, ]; const fullNameDirections = [ @@ -380,6 +381,12 @@ export const resources: RA = [ description: setupToolText.preloadTreeDescription(), type: 'boolean', }, + { + name: 'preloadFile', + label: setupToolText.treeToPreload(), + description: setupToolText.preloadTreeDescription(), + type: 'tree', + }, ], }, { diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx index c17108caa50..c15db718789 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/CreateTree.tsx @@ -330,7 +330,7 @@ function EmptyTreeList({ ); } -function PopulatedTreeList({ +export function PopulatedTreeList({ handleClick, }: { readonly handleClick: (resource: TaxonFileDefaultDefinition) => void; @@ -362,7 +362,7 @@ function PopulatedTreeList({ {resource.description}
- {`Source: ${resource.src}`} + {`${treeText.source()}: ${resource.src}`}
))} diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index cdc8018a02d..6a04cd2c02d 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -132,6 +132,12 @@ export const setupToolText = createDictionary({ preloadTreeDescription: { 'en-us': 'Download default records for this tree.', }, + treeToPreload: { + 'en-us': 'Tree to pre-load:', + }, + selectATree: { + 'en-us': 'Select a tree', + }, include: { 'en-us': 'Include', }, diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index b5613d643d9..8cdc5bd75ec 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -736,6 +736,9 @@ export const treeText = createDictionary({ 'en-us': 'Creating tree record {current:number|formatted}/{total:number|formatted}', }, + source: { + 'en-us': 'Source' + }, treeManagement: { 'en-us': 'Tree Management', 'de-ch': 'Baumpflege', From 522043938027e5d42e317cc0fe1b7c1bbd8df649 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 10:16:13 -0600 Subject: [PATCH 39/52] Enable pre-loading when selecting taxon tree --- .../frontend/js_src/lib/components/SetupTool/SetupForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 2e2fd6112e6..4af016b699f 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -241,7 +241,6 @@ export function renderFormFieldFactory({ const tree = selectedTree as TaxonFileDefaultDefinition; return (
-
{commonText.selected()}
{tree.title}
{tree.description}
@@ -261,6 +260,7 @@ export function renderFormFieldFactory({ handleClick={ (resource: TaxonFileDefaultDefinition): void => { handleChange(fieldName, resource); + handleChange('preload', true); handleTreeDialogClose(); } } From 10604333a4e35b84727f2fa90248e7d04b7ed898 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 10:23:34 -0600 Subject: [PATCH 40/52] Fix tests --- .../js_src/lib/components/SetupTool/index.tsx | 12 ------------ .../components/SystemConfigurationTool/Hierarchy.tsx | 3 ++- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index ae301baffa6..276ed9816d2 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -123,18 +123,6 @@ export function SetupTool({ }, [formData, temporaryFormData, currentStep]); const SubmitComponent = saveBlocked ? Submit.Danger : Submit.Save; - // Fetch list of available default trees. - const [treeOptions, setTreeOptions] = React.useState< - TaxonFileDefaultList | undefined - >(undefined); - React.useEffect(() => { - fetchDefaultTrees() - .then((data) => setTreeOptions(data)) - .catch((error) => { - console.error('Failed to fetch tree options:', error); - }); - }, []); - // Keep track of the last backend error. const [setupError, setSetupError] = React.useState( setupProgress.last_error diff --git a/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx b/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx index 700ad912c0f..cc7dd8b5524 100644 --- a/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx +++ b/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx @@ -32,6 +32,7 @@ import { resources } from '../SetupTool/setupResources'; import type { ResourceFormData } from '../SetupTool/types'; import { CollapsibleSection } from './CollapsibleSection'; import type { InstitutionData } from './Utils'; +import { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; type HierarchyNodeKind = | 'collection' @@ -287,7 +288,7 @@ function DialogForm({ open, onClose, title, step, refreshAllInfo }: DialogFormPr const [temporaryFormData, setTemporaryFormData] = React.useState({}); - const handleChange = (name: string, newValue: LocalizedString | boolean) => { + const handleChange = (name: string, newValue: LocalizedString | boolean | TaxonFileDefaultDefinition) => { const resourceName = resources[5].resourceName; setFormData((previous) => ({ ...previous, From ad19264eaaa92c38ac391386d08555223b8a19fd Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 10:27:04 -0600 Subject: [PATCH 41/52] Fix tests --- specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index 276ed9816d2..7d751240172 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -16,8 +16,6 @@ import { Link } from '../Atoms/Link'; import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; import { loadingBar } from '../Molecules'; -import type { TaxonFileDefaultList } from '../TreeView/CreateTree'; -import { fetchDefaultTrees } from '../TreeView/CreateTree'; import { checkFormCondition, renderFormFieldFactory } from './SetupForm'; import { SetupOverview } from './SetupOverview'; import type { FieldConfig, ResourceConfig } from './setupResources'; From 5b78ac52dcb9b7254818c8d664961f2802e5bb00 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 16:31:02 +0000 Subject: [PATCH 42/52] Lint code with ESLint and Prettier Triggered by ad19264eaaa92c38ac391386d08555223b8a19fd on branch refs/heads/issue-7593 --- specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx index 7d751240172..3fe879705d2 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -16,6 +16,7 @@ import { Link } from '../Atoms/Link'; import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; import { loadingBar } from '../Molecules'; +import type { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; import { checkFormCondition, renderFormFieldFactory } from './SetupForm'; import { SetupOverview } from './SetupOverview'; import type { FieldConfig, ResourceConfig } from './setupResources'; @@ -27,7 +28,6 @@ import type { SetupResponse, } from './types'; import { flattenAllResources } from './utils'; -import type { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; const SETUP_POLLING_INTERVAL = 3000; @@ -189,7 +189,7 @@ export function SetupTool({ const handleChange = ( name: string, - newValue: LocalizedString | boolean | TaxonFileDefaultDefinition + newValue: LocalizedString | TaxonFileDefaultDefinition | boolean ): void => { setFormData((previous: ResourceFormData) => { const resourceName = resources[currentStep].resourceName; From 6bcc7f53b4e7b970fd3dc376e06252d13a75b52f Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 14:10:31 -0600 Subject: [PATCH 43/52] Fix labels Improve styling --- .../lib/components/SetupTool/SetupForm.tsx | 40 ++++++++++--------- .../components/SetupTool/setupResources.ts | 8 ++-- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 1d262f2a183..c067c333549 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -7,6 +7,7 @@ import type { LocalizedString } from 'typesafe-i18n'; import { commonText } from '../../localization/common'; import { userText } from '../../localization/user'; +import { setupToolText } from '../../localization/setupTool'; import { type RA } from '../../utils/types'; import { H3 } from '../Atoms'; import { Input, Label, Select } from '../Atoms/Form'; @@ -84,7 +85,7 @@ export function renderFormFieldFactory({ const renderFormField = ( field: FieldConfig, parentName?: string, - suppressLabel: boolean = false, + inTable: boolean = false, ): JSX.Element => { const { name, @@ -96,7 +97,7 @@ export function renderFormFieldFactory({ fields, passwordRepeat, width, - collapse, + isTable, } = field; const fieldName = parentName === undefined ? name : `${parentName}.${name}`; @@ -141,7 +142,7 @@ export function renderFormFieldFactory({ handleChange(fieldName, isChecked) } /> - {!suppressLabel && label} + {!inTable && label}
) : type === 'select' && Array.isArray(options) ? ( @@ -149,7 +150,7 @@ export function renderFormFieldFactory({ className="mb-4" key={`${resources[currentStep].resourceName}.${fieldName}`} > - {!suppressLabel && ( + {!inTable && ( {label} @@ -176,7 +177,7 @@ export function renderFormFieldFactory({ ) : type === 'password' ? ( <> - {!suppressLabel && label} + {!inTable && label} - {!suppressLabel && passwordRepeat.label} + {!inTable && passwordRepeat.label} {label} - {fields ? renderFormFields(fields, fieldName, collapse === true) : null} + {fields ? renderFormFields(fields, fieldName, isTable === true) : null}
) : ( - {!suppressLabel && label} + {!inTable && label} , parentName?: string, table: boolean = false): JSX.Element => { - if (table && fields.length > 0 && fields[0].fields) { + const renderFormFields = (fields: RA, parentName?: string, isTable: boolean = false): JSX.Element => { + if (isTable && fields.length > 0 && fields[0].fields) { + // Table format specifically for tree rank configuration return (
- +
- + {fields[0].fields!.map((subField) => ( - {fields.map((field, index) => ( + {fields.map((field) => ( {field.fields!.map((subField) => ( - ))} @@ -302,6 +303,7 @@ export function renderFormFieldFactory({ ); } + // Otherwise, lay out fields normally return (
{fields.map((field) => renderFormField(field, parentName))} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index 9d3d46a6706..f6ec58d74e1 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -41,7 +41,7 @@ export type FieldConfig = { }; readonly maxLength?: number; readonly width?: number; - readonly collapse?: boolean; + readonly isTable?: boolean; }; // Discipline list from backend/context/app_resource.py @@ -221,7 +221,7 @@ export const resources: RA = [ label: setupToolText.treeRanks(), required: false, type: 'object', - collapse: true, + isTable: true, // TODO: Rank fields should be generated from a .json file. fields: generateTreeRankFields( [ @@ -289,7 +289,7 @@ export const resources: RA = [ label: setupToolText.treeRanks(), required: false, type: 'object', - collapse: true, + isTable: true, fields: generateTreeRankFields( ['Earth', 'Continent', 'Country', 'State', 'County'], ['Earth', 'Continent', 'Country', 'State', 'County'], @@ -324,7 +324,7 @@ export const resources: RA = [ * label: setupToolText.treeRanks(), * required: false, * type: 'object', - * collapse: true, + * isTable: true, * fields: generateTreeRankFields( * [ * 'Life', From f26a957a730900fbd6bb3fe84add758a0c376997 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 14:10:45 -0600 Subject: [PATCH 44/52] Make table rounded --- .../frontend/js_src/lib/components/SetupTool/SetupForm.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index c067c333549..36c31025b4d 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -261,10 +261,10 @@ export function renderFormFieldFactory({ if (isTable && fields.length > 0 && fields[0].fields) { // Table format specifically for tree rank configuration return ( -
-
- Rank + {setupToolText.treeRanks()}
{field.label} + {renderFormField( subField, - parentName === undefined ? field.name : `${parentName}.${field.name}` + parentName === undefined ? field.name : `${parentName}.${field.name}`, + true )}
+
+
- + From fed170c205e34cd7d02cab8afbf020eec70a0fa1 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 14:30:07 -0600 Subject: [PATCH 45/52] Fix table borders --- .../js_src/lib/components/SetupTool/SetupForm.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index d989a08db51..279122d746f 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -7,7 +7,6 @@ import type { LocalizedString } from 'typesafe-i18n'; import { commonText } from '../../localization/common'; import { userText } from '../../localization/user'; -import { setupToolText } from '../../localization/setupTool'; import { type RA } from '../../utils/types'; import { H3 } from '../Atoms'; import { Input, Label, Select } from '../Atoms/Form'; @@ -290,13 +289,13 @@ export function renderFormFieldFactory({
{setupToolText.treeRanks()}
- {fields[0].fields!.map((subField) => ( @@ -307,13 +306,13 @@ export function renderFormFieldFactory({ {fields.map((field) => ( - {field.fields!.map((subField) => ( - - {fields[0].fields!.map((subField) => ( + {fields[0].fields.map((subField) => ( @@ -312,14 +312,14 @@ export function renderFormFieldFactory({ {fields.map((field) => ( {field.fields!.map((subField) => ( - From e1555e2efbd35c09fcf95b73e7547b62d1fff490 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 22:11:50 +0000 Subject: [PATCH 50/52] Lint code with ESLint and Prettier Triggered by f32255cb0758cc1b94f9a2e4bd11306d9560feb7 on branch refs/heads/issue-7615 --- .../Notifications/NotificationRenderers.tsx | 8 +- .../lib/components/SetupTool/SetupForm.tsx | 80 +++++++++++-------- .../components/SetupTool/SetupOverview.tsx | 3 +- .../js_src/lib/components/SetupTool/utils.ts | 26 +++--- .../SystemConfigurationTool/Hierarchy.tsx | 50 +++++++++--- .../js_src/lib/components/TreeView/Tree.tsx | 7 +- .../frontend/js_src/lib/localization/tree.ts | 2 +- 7 files changed, 112 insertions(+), 64 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx index 7548864725b..074dff359a3 100644 --- a/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Notifications/NotificationRenderers.tsx @@ -367,7 +367,7 @@ export const notificationRenderers: IR<

{treeText.defaultTreeTaskStarted()}

{notification.payload.name} - ) + ); }, 'create-default-tree-failed'(notification) { return ( @@ -375,7 +375,7 @@ export const notificationRenderers: IR<

{treeText.defaultTreeTaskFailed()}

{notification.payload.name} - ) + ); }, 'create-default-tree-cancelled'(notification) { return ( @@ -383,7 +383,7 @@ export const notificationRenderers: IR<

{treeText.defaultTreeTaskCancelled()}

{notification.payload.name} - ) + ); }, 'create-default-tree-completed'(notification) { return ( @@ -391,7 +391,7 @@ export const notificationRenderers: IR<

{treeText.defaultTreeTaskCompleted()}

{notification.payload.name} - ) + ); }, default(notification) { console.error('Unknown notification type', { notification }); diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index e4efec184af..2b5627ed5c1 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -73,12 +73,13 @@ export function renderFormFieldFactory({ ) => void; readonly formRef: React.MutableRefObject; }) { - const [isTreeDialogOpen, handleTreeDialogOpen, handleTreeDialogClose] = useBooleanState(false); + const [isTreeDialogOpen, handleTreeDialogOpen, handleTreeDialogClose] = + useBooleanState(false); const renderFormField = ( field: FieldConfig, parentName?: string, - inTable: boolean = false, + inTable: boolean = false ): JSX.Element => { const { name, @@ -95,9 +96,14 @@ export function renderFormFieldFactory({ const fieldName = parentName === undefined ? name : `${parentName}.${name}`; - const colSpan = (width === undefined) ? (type === 'object' ? 'col-span-4' : 'col-span-2') : `col-span-${width}`; + const colSpan = + width === undefined + ? type === 'object' + ? 'col-span-4' + : 'col-span-2' + : `col-span-${width}`; - const verticalSpacing = (width !== undefined && width < 2) ? '-mb-2' : 'mb-2' + const verticalSpacing = width !== undefined && width < 2 ? '-mb-2' : 'mb-2'; const disciplineTypeValue = resources[currentStep].resourceName === 'discipline' @@ -108,8 +114,7 @@ export function renderFormFieldFactory({ fieldName === 'name' && (disciplineTypeValue === undefined || disciplineTypeValue === ''); - const showTreeSelector = - getFormValue(formData, currentStep, 'preload'); + const showTreeSelector = getFormValue(formData, currentStep, 'preload'); return (
@@ -135,11 +140,7 @@ export function renderFormFieldFactory({ className="mb-4" key={`${resources[currentStep].resourceName}.${fieldName}`} > - {!inTable && ( - - {label} - - )} + {!inTable && {label}}
diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx index 033aeb3b4e6..872457a40ac 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx @@ -84,7 +84,8 @@ export function SetupOverview({ } else if (field.type === 'boolean') { value = rawValue === true ? queryText.yes() : commonText.no(); } else if (field.type === 'tree') { - value = typeof rawValue === 'object' ? String(rawValue.title) : '-'; + value = + typeof rawValue === 'object' ? String(rawValue.title) : '-'; } return ( diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts index 507b3ab86ac..9853087e175 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/utils.ts @@ -1,16 +1,24 @@ // Turn 'table.field' keys to nested objects to send to the backend -function setNested(obj: Record, path: string, value: any): void { - const idx = path.indexOf('.'); - if (idx === -1) { - obj[path] = value; +function setNested( + object: Record, + path: string, + value: any +): void { + const index = path.indexOf('.'); + if (index === -1) { + object[path] = value; return; } - const head = path.slice(0, idx); - const rest = path.slice(idx + 1); - if (obj[head] === undefined || typeof obj[head] !== 'object' || Array.isArray(obj[head])) { - obj[head] = {}; + const head = path.slice(0, index); + const rest = path.slice(index + 1); + if ( + object[head] === undefined || + typeof object[head] !== 'object' || + Array.isArray(object[head]) + ) { + object[head] = {}; } - setNested(obj[head], rest, value); + setNested(object[head], rest, value); } function flattenToNested(data: Record): Record { diff --git a/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx b/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx index cc7dd8b5524..b5e02510671 100644 --- a/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx +++ b/specifyweb/frontend/js_src/lib/components/SystemConfigurationTool/Hierarchy.tsx @@ -30,9 +30,9 @@ import { stepOrder } from '../SetupTool'; import { renderFormFieldFactory } from '../SetupTool/SetupForm'; import { resources } from '../SetupTool/setupResources'; import type { ResourceFormData } from '../SetupTool/types'; +import type { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; import { CollapsibleSection } from './CollapsibleSection'; import type { InstitutionData } from './Utils'; -import { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; type HierarchyNodeKind = | 'collection' @@ -144,7 +144,10 @@ function HierarchyDiagram({ const zoomBehavior = d3Zoom() .scaleExtent([0.5, 8]) - .wheelDelta((wheelEvent: WheelEvent) => -wheelEvent.deltaY * (wheelEvent.deltaMode === 1 ? 0.05 : 0.002)) + .wheelDelta( + (wheelEvent: WheelEvent) => + -wheelEvent.deltaY * (wheelEvent.deltaMode === 1 ? 0.05 : 0.002) + ) .on('zoom', (event) => { setTransform(event.transform); }); @@ -246,14 +249,16 @@ function HierarchyDiagram({ width={NODE_WIDTH} y={6} /> - +
{node.data.name}
-
- {textByKind[node.data.kind]} -
+
{textByKind[node.data.kind]}
@@ -274,7 +279,13 @@ type DialogFormProps = { readonly refreshAllInfo: () => Promise; }; -function DialogForm({ open, onClose, title, step, refreshAllInfo }: DialogFormProps) { +function DialogForm({ + open, + onClose, + title, + step, + refreshAllInfo, +}: DialogFormProps) { const id = useId('config-tool'); if (!open) return null; @@ -288,7 +299,10 @@ function DialogForm({ open, onClose, title, step, refreshAllInfo }: DialogFormPr const [temporaryFormData, setTemporaryFormData] = React.useState({}); - const handleChange = (name: string, newValue: LocalizedString | boolean | TaxonFileDefaultDefinition) => { + const handleChange = ( + name: string, + newValue: LocalizedString | TaxonFileDefaultDefinition | boolean + ) => { const resourceName = resources[5].resourceName; setFormData((previous) => ({ ...previous, @@ -440,7 +454,10 @@ export function Hierarchy({ className="flex items-center gap-2 flex-wrap bg-[color:var(--background)] rounded px-2 py-1" key={collection.id} > -

{`${tableLabel('Collection')}:`}

+

{`${tableLabel('Collection')}:`}

{collection.name}

{handleEditResource( @@ -468,7 +485,10 @@ export function Hierarchy({
-

{`${tableLabel('Discipline')}:`}

+

{`${tableLabel('Discipline')}:`}

{discipline.name}

@@ -565,7 +585,10 @@ export function Hierarchy({
-

{`${tableLabel('Division')}:`}

+

{`${tableLabel('Division')}:`}

{division.name}

@@ -614,7 +637,10 @@ export function Hierarchy({ title={
-

{`${tableLabel('Institution')}:`}

+

{`${tableLabel('Institution')}:`}

{institution.name}

diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index 76c6f35f961..d92315976c1 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -23,10 +23,10 @@ import { hasTablePermission } from '../Permissions/helpers'; import { useHighContrast } from '../Preferences/Hooks'; import { userPreferences } from '../Preferences/userPreferences'; import { AddRank } from './AddRank'; +import { ImportTree } from './CreateTree'; import type { Conformations, Row, Stats } from './helpers'; import { fetchStats } from './helpers'; import { TreeRow } from './Row'; -import { ImportTree } from './CreateTree' const treeToPref = { Geography: 'geography', @@ -226,10 +226,7 @@ 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 8cdc5bd75ec..1891fd5e8a7 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -737,7 +737,7 @@ export const treeText = createDictionary({ 'Creating tree record {current:number|formatted}/{total:number|formatted}', }, source: { - 'en-us': 'Source' + 'en-us': 'Source', }, treeManagement: { 'en-us': 'Tree Management', From 169da7802d2fc1a077b2ef6ba0c9158fb13cf67a Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 16:41:59 -0600 Subject: [PATCH 51/52] Fix mapping errors --- .../backend/setup_tool/schema_defaults.py | 245 ++++++++++++++++-- .../migration_utils/update_schema_config.py | 211 --------------- 2 files changed, 224 insertions(+), 232 deletions(-) diff --git a/specifyweb/backend/setup_tool/schema_defaults.py b/specifyweb/backend/setup_tool/schema_defaults.py index bcf5e040b06..dafb45fb259 100644 --- a/specifyweb/backend/setup_tool/schema_defaults.py +++ b/specifyweb/backend/setup_tool/schema_defaults.py @@ -1,9 +1,21 @@ from typing import Optional -# from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id -# from specifyweb.specify.migration_utils.update_schema_config import update_table_schema_config_with_defaults -from specifyweb.specify.migration_utils.update_schema_config import create_default_table_schema_config +from django.db import transaction +from django.apps import apps as global_apps +from django.core.exceptions import MultipleObjectsReturned +from specifyweb.specify.migration_utils.update_schema_config import ( + camel_to_spaced_title_case, + datamodel_type_to_schematype, + FieldSchemaConfig, + HIDDEN_FIELDS, + uncapitilize, +) from .utils import load_json_from_file -from specifyweb.specify.models import Discipline +from specifyweb.specify.models import ( + Discipline, + datamodel, +) +from typing import NamedTuple, Literal +from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id from pathlib import Path @@ -41,23 +53,214 @@ def apply_schema_defaults(discipline: Discipline): continue defaults.setdefault(table_name, {})[key] = v - # Update the schema for each table individually. - # for model_name in model_names_by_table_id.values(): - # logger.debug(f'Applying schema defaults for {model_name}. Using defaults: {overrides is not None}.') - - # # Table information - # table_defaults = defaults.get(model_name.lower()) - # table_description = None - # if table_defaults: - # table_description = table_defaults.get('desc') - - # update_table_schema_config_with_defaults( - # table_name=model_name, - # discipline_id=discipline.id, - # description=table_description, - # defaults=table_defaults, - # ) create_default_table_schema_config( discipline_id=discipline.id, defaults=defaults, - ) \ No newline at end of file + ) + +def create_default_table_schema_config( + discipline_id: int, + apps = global_apps, + defaults: dict = None, +): + """Creates all schema config localization records. Assumes none exist.""" + Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') + Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') + Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') + + with transaction.atomic(): + container_batch = [] + item_batch = [] + str_batch = [] + + # These seemingly redundant loops are used for much needed batching + + # Create table containers # + for table_name in model_names_by_table_id.values(): + container_batch.append(create_table_container( + table_name, + discipline_id + )) + Splocalecontainer.objects.bulk_create(container_batch, ignore_conflicts=True) + + # Create a map to reference the saved parent containers later + saved_containers = Splocalecontainer.objects.filter( + name__in=[c.name for c in container_batch], + discipline_id=discipline_id + ) + container_map = {c.name: c for c in saved_containers} + + # Create items for all fields in every table # + for table_name in model_names_by_table_id.values(): + table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() + if table_defaults is None: + table_defaults = dict() + + container = container_map[table.name.lower()] + + for field in table.all_fields: + field_defaults = None + if table_defaults.get('items'): + field_defaults = table_defaults['items'].get(field.name.lower()) + + item_batch.extend(create_field_item( + field.name, + table_name, + container, + field_defaults + )) + + Splocalecontaineritem.objects.bulk_create(item_batch, ignore_conflicts=True) + + saved_items = Splocalecontaineritem.objects.filter( + container__name__in=[c.name for c in container_batch], + container__discipline_id=discipline_id + ) + item_map = {(i.container.name, i.name): i for i in saved_items} + + # Create strings for names and descriptions belonging all tables and their fields # + for table_name in model_names_by_table_id.values(): + table = datamodel.get_table(table_name) + table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() + if table_defaults is None: + table_defaults = dict() + + container = container_map[table.name.lower()] + + str_batch.extend(create_table_strings( + table_name, + container, + table_defaults + )) + + for field in table.all_fields: + field_name = field.name.lower() + + field_defaults = None + if table_defaults.get('items'): + field_defaults = table_defaults['items'].get(field_name) + + item_key = (container.name, field_name) + item = item_map[item_key] + + str_batch.extend( + create_field_strings( + field_name, + table_name, + item, + field_defaults, + ) + ) + Splocaleitemstr.objects.bulk_create(str_batch, ignore_conflicts=True) + +def create_table_container( + table_name: str, + discipline_id: int, + apps = global_apps, +): + Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') + table = datamodel.get_table(table_name) + + return Splocalecontainer( + name=table.name.lower(), + discipline_id=discipline_id, + schematype=0, + ishidden=False, + issystem=table.system, + version=0, + ) + +def create_field_item( + field_name: str, + table_name: str, + container, + field_defaults: dict = None, + apps = global_apps, +): + Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') + + table = datamodel.get_table(table_name) + field = table.get_field_strict(field_name) + field_name_str = camel_to_spaced_title_case(field.name) + field_desc_str = camel_to_spaced_title_case(field.name) + field_hidden = field_name.lower() in HIDDEN_FIELDS + field_required = field.required + picklist_name = None + if field_defaults is not None: + field_name_str = field_defaults.get('name', field_name_str) + field_desc_str = field_defaults.get('desc', field_desc_str) + field_hidden = field_defaults.get('ishidden', field_hidden) + field_required = field_defaults.get('isrequired', field_required) + picklist_name = field_defaults.get('picklistname', picklist_name) + + field_config = FieldSchemaConfig( + name=field_name, + column=field.column, + java_type=datamodel_type_to_schematype(field.type) if field.is_relationship else field.type, + description=field_desc_str, + language="en" + ) + + return Splocalecontaineritem( + name=field_config.name, + container=container, + type=field_config.java_type, + ishidden=field_hidden, + isrequired=field_required, + issystem=table.system, + version=0, + picklistname=picklist_name + ) + +def create_table_strings( + table_name: str, + container, + table_defaults: dict = None, + apps = global_apps, +): + table = datamodel.get_table(table_name) + table_name_str = table_defaults.get('name', camel_to_spaced_title_case(uncapitilize(table.name))) + table_desc_str = table_defaults.get('desc', camel_to_spaced_title_case(uncapitilize(table.name))) + + return create_strings('container', container, table_name_str, table_desc_str, apps) + +def create_field_strings( + field_name: str, + table_name: str, + item, + field_defaults: dict = None, + apps = global_apps, +): + table = datamodel.get_table(table_name) + field = table.get_field_strict(field_name) + field_name = field.name + field = table.get_field_strict(field_name) + field_name_str = camel_to_spaced_title_case(field.name) + field_desc_str = camel_to_spaced_title_case(field.name) + if field_defaults is not None: + field_name_str = field_defaults.get('name', field_name_str) + field_desc_str = field_defaults.get('desc', field_desc_str) + + return create_strings('item', item, field_name_str, field_desc_str, apps) + +def create_strings( + parent_type: Literal['item', 'container'], + item, + name_str, + desc_str, + apps = global_apps, +): + Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') + strings = [] + for k, text in { + f"{parent_type}name": name_str, + f"{parent_type}desc": desc_str, + }.items(): + itm_str = { + 'text': text, + 'language': 'en', + 'version': 0, + } + itm_str[k] = item + strings.append(Splocaleitemstr(**itm_str)) + return strings \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/update_schema_config.py b/specifyweb/specify/migration_utils/update_schema_config.py index 1664c88f658..eb22e401520 100644 --- a/specifyweb/specify/migration_utils/update_schema_config.py +++ b/specifyweb/specify/migration_utils/update_schema_config.py @@ -84,217 +84,6 @@ class FieldSchemaConfig(NamedTuple): def uncapitilize(string: str) -> str: return string.lower() if len(string) <= 1 else string[0].lower() + string[1:] -from typing import NamedTuple, Literal -from django.db import transaction -from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id - -def create_default_table_schema_config( - discipline_id: int, - apps = global_apps, - defaults: dict = None, -): - """Creates all schema config localization records. Assumes none exist.""" - Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') - Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') - Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') - - with transaction.atomic(): - container_batch = [] - item_batch = [] - str_batch = [] - - # These seemingly redundant loops are used for much needed batching - - # Create table containers # - for table_name in model_names_by_table_id.values(): - container_batch.append(create_table_container( - table_name, - discipline_id - )) - Splocalecontainer.objects.bulk_create(container_batch, ignore_conflicts=True) - - # Create a map to reference the saved parent containers later - saved_containers = Splocalecontainer.objects.filter( - name__in=[c.name for c in container_batch], - discipline_id=discipline_id - ) - container_map = {c.name: c for c in saved_containers} - - # Create items for all fields in every table # - for table_name in model_names_by_table_id.values(): - table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() - if table_defaults is None: - table_defaults = dict() - - container = container_map[table.name.lower()] - - for field in table.all_fields: - field_defaults = None - if table_defaults.get('items'): - field_defaults = table_defaults['items'].get(field.name.lower()) - - item_batch.extend(create_field_item( - field.name, - table_name, - container, - field_defaults - )) - - Splocalecontaineritem.objects.bulk_create(item_batch, ignore_conflicts=True) - - saved_items = Splocalecontaineritem.objects.filter( - container__name__in=[c.name for c in container_batch], - container__discipline_id=discipline_id - ) - item_map = {(i.container.name, i.name): i for i in saved_items} - - # Create strings for names and descriptions belonging all tables and their fields # - for table_name in model_names_by_table_id.values(): - table = datamodel.get_table(table_name) - table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() - if table_defaults is None: - table_defaults = dict() - - container = container_map[table.name.lower()] - - str_batch.extend(create_table_strings( - table_name, - container, - table_defaults - )) - - for field in table.all_fields: - field_name = field.name.lower() - - field_defaults = None - if table_defaults.get('items'): - field_defaults = table_defaults['items'].get(field_name) - - item_key = (container.name, field_name) - item = item_map[item_key] - - str_batch.extend( - create_field_strings( - field_name, - table_name, - item, - field_defaults, - ) - ) - Splocaleitemstr.objects.bulk_create(str_batch, ignore_conflicts=True) - -def create_table_container( - table_name: str, - discipline_id: int, - apps = global_apps, -): - Splocalecontainer = apps.get_model('specify', 'Splocalecontainer') - table = datamodel.get_table(table_name) - - return Splocalecontainer( - name=table.name.lower(), - discipline_id=discipline_id, - schematype=0, - ishidden=False, - issystem=table.system, - version=0, - ) - -def create_field_item( - field_name: str, - table_name: str, - container, - field_defaults: dict = None, - apps = global_apps, -): - Splocalecontaineritem = apps.get_model('specify', 'Splocalecontaineritem') - - table = datamodel.get_table(table_name) - field = table.get_field_strict(field_name) - field_name_str = camel_to_spaced_title_case(field.name) - field_desc_str = camel_to_spaced_title_case(field.name) - field_hidden = field_name.lower() in HIDDEN_FIELDS - field_required = field.required - picklist_name = None - if field_defaults is not None: - field_name_str = field_defaults.get('name', field_name_str) - field_desc_str = field_defaults.get('desc', field_desc_str) - field_hidden = field_defaults.get('ishidden', field_hidden) - field_required = field_defaults.get('isrequired', field_required) - picklist_name = field_defaults.get('picklistname', picklist_name) - - field_config = FieldSchemaConfig( - name=field_name, - column=field.column, - java_type=datamodel_type_to_schematype(field.type) if field.is_relationship else field.type, - description=field_desc_str, - language="en" - ) - - return Splocalecontaineritem( - name=field_config.name, - container=container, - type=field_config.java_type, - ishidden=field_hidden, - isrequired=field_required, - issystem=table.system, - version=0, - picklistname=picklist_name - ) - -def create_table_strings( - table_name: str, - container, - table_defaults: dict = None, - apps = global_apps, -): - table = datamodel.get_table(table_name) - table_name_str = table_defaults.get('name', camel_to_spaced_title_case(uncapitilize(table.name))) - table_desc_str = table_defaults.get('desc', camel_to_spaced_title_case(uncapitilize(table.name))) - - return create_strings('container', container, table_name_str, table_desc_str, apps) - -def create_field_strings( - field_name: str, - table_name: str, - item, - field_defaults: dict = None, - apps = global_apps, -): - table = datamodel.get_table(table_name) - field = table.get_field_strict(field_name) - field_name = field.name - field = table.get_field_strict(field_name) - field_name_str = camel_to_spaced_title_case(field.name) - field_desc_str = camel_to_spaced_title_case(field.name) - if field_defaults is not None: - field_name_str = field_defaults.get('name', field_name_str) - field_desc_str = field_defaults.get('desc', field_desc_str) - - return create_strings('item', item, field_name_str, field_desc_str, apps) - -def create_strings( - parent_type: Literal['item', 'container'], - item, - name_str, - desc_str, - apps = global_apps, -): - Splocaleitemstr = apps.get_model('specify', 'Splocaleitemstr') - strings = [] - for k, text in { - f"{parent_type}name": name_str, - f"{parent_type}desc": desc_str, - }.items(): - itm_str = { - 'text': text, - 'language': 'en', - 'version': 0, - } - itm_str[k] = item - strings.append(Splocaleitemstr(**itm_str)) - return strings - def update_table_schema_config_with_defaults( table_name, discipline_id: int, From 9abc2529a8d602fd252dadf453790a18ae67faad Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 16:53:44 -0600 Subject: [PATCH 52/52] Fix schema mapping errors --- specifyweb/backend/setup_tool/schema_defaults.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/specifyweb/backend/setup_tool/schema_defaults.py b/specifyweb/backend/setup_tool/schema_defaults.py index dafb45fb259..c52fb490e8e 100644 --- a/specifyweb/backend/setup_tool/schema_defaults.py +++ b/specifyweb/backend/setup_tool/schema_defaults.py @@ -92,18 +92,19 @@ def create_default_table_schema_config( # Create items for all fields in every table # for table_name in model_names_by_table_id.values(): + table = datamodel.get_table(table_name) table_defaults = defaults.get(table_name.lower()) if defaults is not None else dict() if table_defaults is None: table_defaults = dict() - container = container_map[table.name.lower()] + container = container_map[table_name.lower()] for field in table.all_fields: field_defaults = None if table_defaults.get('items'): field_defaults = table_defaults['items'].get(field.name.lower()) - item_batch.extend(create_field_item( + item_batch.append(create_field_item( field.name, table_name, container, @@ -116,7 +117,7 @@ def create_default_table_schema_config( container__name__in=[c.name for c in container_batch], container__discipline_id=discipline_id ) - item_map = {(i.container.name, i.name): i for i in saved_items} + item_map = {(i.container.name.lower(), i.name.lower()): i for i in saved_items} # Create strings for names and descriptions belonging all tables and their fields # for table_name in model_names_by_table_id.values(): @@ -140,7 +141,7 @@ def create_default_table_schema_config( if table_defaults.get('items'): field_defaults = table_defaults['items'].get(field_name) - item_key = (container.name, field_name) + item_key = (container.name.lower(), field_name.lower()) item = item_map[item_key] str_batch.extend( @@ -217,7 +218,7 @@ def create_table_strings( container, table_defaults: dict = None, apps = global_apps, -): +) -> list: table = datamodel.get_table(table_name) table_name_str = table_defaults.get('name', camel_to_spaced_title_case(uncapitilize(table.name))) table_desc_str = table_defaults.get('desc', camel_to_spaced_title_case(uncapitilize(table.name)))
+ {setupToolText.treeRanks()} {subField.label}
+ {field.label} + {renderFormField( subField, parentName === undefined ? field.name : `${parentName}.${field.name}`, From f4783f5e4216b2241e1bc5e364d62eb72fa1e4d4 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 14:32:31 -0600 Subject: [PATCH 46/52] [Guided Setup] Optimize default tree creation (#7669) * optimize tree creation * Fix duplication * Renumber tree and set full names * Lint code with ESLint and Prettier Triggered by cb60fbce2f3cce3b3a0ff18bed0c1c565165cada on branch refs/heads/issue-7641-1 --- specifyweb/backend/trees/defaults.py | 134 +++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 18 deletions(-) diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py index 56a2b5200ed..8b9a2877a5c 100644 --- a/specifyweb/backend/trees/defaults.py +++ b/specifyweb/backend/trees/defaults.py @@ -11,6 +11,7 @@ from specifyweb.celery_tasks import LogErrorsTask, app import specifyweb.specify.models as spmodels from specifyweb.backend.trees.utils import get_models, SPECIFY_TREES, TREE_ROOT_NODES +from specifyweb.backend.trees.extras import renumber_tree, set_fullnames import logging logger = logging.getLogger(__name__) @@ -136,20 +137,105 @@ def __init__(self, tree_type: str, tree_name: str): self.tree_def_model, self.tree_rank_model, self.tree_node_model = get_models(tree_type) self.tree_def = self.tree_def_model.objects.get(name=tree_name) - self.tree_def_item_map = self.create_rank_map() + + self.create_rank_map() self.root_parent = self.tree_node_model.objects.filter( definitionitem__rankid=0, definition=self.tree_def ).first() + self.counter = 0 + self.batch_size = 1000 + def create_rank_map(self): """Rank lookup map to reduce queries""" - return { - rank.name: rank - for rank in self.tree_rank_model.objects.filter(treedef=self.tree_def) - } + ranks = list(self.tree_rank_model.objects.filter(treedef=self.tree_def)) + self.tree_def_item_map = {rank.name: rank for rank in ranks} + # Buffers for batches + self.rankid_map = {rank.rankid: rank for rank in ranks} + self.buffers = {rank.rankid: {} for rank in ranks} + self.created = {rank.rankid: {} for rank in ranks} + + def add_node_to_buffer(self, node, rank_id, row_id): + """Add node to the current batch of nodes to be created""" + if rank_id not in self.buffers: + self.buffers[rank_id] = {} + self.created[rank_id] = {} + self.buffers[rank_id][row_id] = node + return node + + def get_node_in_buffer(self, rank_id: int, name: str): + """Gets a node if its already in the current batch's buffer. Prevents duplication within a batch.""" + # Check for node in buffer, return node + buffer = self.buffers.get(rank_id, {}) + for node in buffer.values(): + if node.name == name: + return node + return None + + def get_existing_node_id(self, rank_id: int, name: str) -> Optional[int]: + """Gets a node's id if it has already been created. Prevents duplication across an entire import.""" + # Check for existing id, return id + created_in_rank = self.created.get(rank_id) + if created_in_rank: + return created_in_rank.get(name) + return None + + def flush(self, force=False): + """Flushes this batch's buffer if the batch is complete. Bulk creates the nodes in a complete batch.""" + self.counter += 1 + if not (force or self.counter > self.batch_size): + return + logger.debug(f"Batch creating {self.batch_size} rows.") + + # Go through ranks in ascending order and bulk create nodes + ordered_rank_ids = sorted(self.buffers.keys()) + for rank_id in ordered_rank_ids: + logger.debug(f"On rank {rank_id}") + buffer = self.buffers.get(rank_id, {}) + + rank = self.rankid_map.get(rank_id) + if rank is None: + # Can't create nodes because this rank doesn't exist + continue -def add_default_tree_record(context: DefaultTreeContext, row: dict, tree_cfg: dict[str, RankMappingConfiguration]): + nodes_to_create = [] + # Update the nodes' parents to a saved version of their parents + for row_id, node in list(buffer.items()): + parent = getattr(node, 'parent', None) + parent_id = getattr(node, 'parent_id', None) + if parent is not None and getattr(parent, 'pk', None) is None: + saved_parent_id = self.created[parent.rankid].get(parent.name) + # Handle root + if not saved_parent_id and parent.name == getattr(self.root_parent, 'name', None): + saved_parent_id = self.root_parent.id + if saved_parent_id: + node.parent = None + node.parent_id = saved_parent_id + + # Create node if its parent has been created + if getattr(node.parent, 'pk', None) is not None or getattr(node, 'parent_id', None) is not None: + nodes_to_create.append(node) + else: + logger.warning(f"Could not create {node.name} because a valid parent could not be resolved. {parent_id}, {str(parent)}") + + if nodes_to_create: + self.tree_node_model.objects.bulk_create(nodes_to_create, ignore_conflicts=True) + + # Store the ids of the nodes were created in this batch + created_names = [n.name for n in nodes_to_create] + created_nodes = self.tree_node_model.objects.filter( + definition=self.tree_def, + definitionitem=rank, + name__in=created_names + ) + self.created[rank_id].update({n.name: n.id for n in created_nodes}) + + self.buffers[rank_id] = {} + + self.counter = 0 + +def add_default_tree_record(context: DefaultTreeContext, row: dict, tree_cfg: dict[str, RankMappingConfiguration], row_id: int): """ Given one CSV row and a column mapping / rank configuration dictionary, walk through the 'ranks' in order, creating or updating each tree record and linking @@ -158,6 +244,7 @@ def add_default_tree_record(context: DefaultTreeContext, row: dict, tree_cfg: di tree_node_model = context.tree_node_model tree_def = context.tree_def parent = context.root_parent + parent_id = None rank_id = 10 for rank_mapping in tree_cfg['ranks']: @@ -187,27 +274,32 @@ def add_default_tree_record(context: DefaultTreeContext, row: dict, tree_cfg: di continue # Create the node at this rank if it isn't already there. - obj = tree_node_model.objects.filter( - name=record_name, - fullname=record_name, - definition=tree_def, - definitionitem=tree_def_item, - parent=parent, - ).first() - if obj is None: + buffered = context.get_node_in_buffer(tree_def_item.rankid, record_name) + existing_id = context.get_existing_node_id(tree_def_item.rankid, record_name) + if existing_id is not None: + parent_id = existing_id + parent = None + elif buffered is not None: + parent_id = None + parent = buffered + else: data = { 'name': record_name, 'fullname': record_name, 'definition': tree_def, 'definitionitem': tree_def_item, - 'parent': parent, 'rankid': tree_def_item.rankid, **defaults } + if parent is not None: + data['parent'] = parent + elif parent_id is not None: + data['parent_id'] = parent_id obj = tree_node_model(**data) - obj.save(skip_tree_extras=True) + obj = context.add_node_to_buffer(obj, tree_def_item.rankid, row_id) - parent = obj + parent = obj + parent_id = None rank_id += 10 @app.task(base=LogErrorsTask, bind=True) @@ -292,8 +384,14 @@ def progress(cur: int, additional_total: int=0) -> None: progress(0, total_rows) for row in stream_csv_from_url(url): - add_default_tree_record(context, row, tree_cfg) + add_default_tree_record(context, row, tree_cfg, current) + context.flush() progress(1, 0) + context.flush(force=True) + + # Finalize Tree + renumber_tree(tree_type) + set_fullnames(tree_def) except Exception as e: if specify_user_id and specify_collection_id: Message.objects.create( From 65400e16be783529fa162fcbe15430c550ff6366 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 15:25:24 -0600 Subject: [PATCH 47/52] Improve tree selector formatting Clean up backend code for trees in setup tool --- specifyweb/backend/setup_tool/api.py | 4 +- .../backend/setup_tool/tree_defaults.py | 6 +- .../lib/components/SetupTool/SetupForm.tsx | 85 ++++++++++--------- .../components/SetupTool/setupResources.ts | 3 + .../js_src/lib/localization/setupTool.ts | 4 +- 5 files changed, 55 insertions(+), 47 deletions(-) diff --git a/specifyweb/backend/setup_tool/api.py b/specifyweb/backend/setup_tool/api.py index 80295c23e35..f59c974c6c0 100644 --- a/specifyweb/backend/setup_tool/api.py +++ b/specifyweb/backend/setup_tool/api.py @@ -396,7 +396,7 @@ def create_tree(name: str, data: dict) -> dict: # Get tree configuration ranks = data.pop('ranks', dict()) - # Pre-load Default Tree + # Properties for pre-loading default tree. Pre-loading should be done once setup is complete. preload_tree = data.pop('preload', None) preload_tree_file = data.pop('preloadFile', None) @@ -408,7 +408,7 @@ def create_tree(name: str, data: dict) -> dict: if use_discipline and discipline is not None: kwargs['discipline'] = discipline - treedef = start_default_tree_from_configuration(name, kwargs, ranks, preload_tree) + treedef = start_default_tree_from_configuration(name, kwargs, ranks) # Set as the primary tree in the discipline if its the first one if use_discipline and discipline: diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py index 00ae354fa6d..8bdb992b410 100644 --- a/specifyweb/backend/setup_tool/tree_defaults.py +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -1,7 +1,5 @@ -from django.db import transaction from django.db.models import Model as DjangoModel -from specifyweb.specify.models import Discipline, Collection -from typing import Type, Optional, List +from typing import Type, Optional from pathlib import Path from uuid import uuid4 import requests @@ -30,7 +28,7 @@ 'Geologictimeperiod': 'https://files.specifysoftware.org/treerows/geologictimeperiod.json', } -def start_default_tree_from_configuration(tree_type: str, kwargs: dict, user_rank_cfg: dict, preload_tree: Optional[bool]): +def start_default_tree_from_configuration(tree_type: str, kwargs: dict, user_rank_cfg: dict): """Starts the creation of an initial empty tree. This should not be used outside of the initial database setup.""" # Load all default ranks for this type of tree if tree_type == 'Taxon': diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 279122d746f..98da7f6363b 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -108,6 +108,9 @@ export function renderFormFieldFactory({ fieldName === 'name' && (disciplineTypeValue === undefined || disciplineTypeValue === ''); + const showTreeSelector = + getFormValue(formData, currentStep, 'preload'); + return (
{type === 'boolean' ? ( @@ -223,47 +226,51 @@ export function renderFormFieldFactory({ {fields ? renderFormFields(fields, fieldName, isTable === true) : null}
) : type === 'tree' ? ( - // Taxon tree selection - - {label} - - {setupToolText.selectATree()} - - {(() => { - // Display the selected tree - const selectedTree = getFormValue(formData, currentStep, fieldName); - if (selectedTree && typeof selectedTree === 'object') { - const tree = selectedTree as TaxonFileDefaultDefinition; - return ( -
-
-
{tree.title}
-
{tree.description}
-
{`${treeText.source()}: ${tree.src}`}
-
-
- ); - } - return null; - })()} - {isTreeDialogOpen ? ( + {label} + - { - handleChange(fieldName, resource); - handleChange('preload', true); - handleTreeDialogClose(); + {setupToolText.selectATree()} + + {(() => { + // Display the selected tree + const selectedTree = getFormValue(formData, currentStep, fieldName); + if (selectedTree && typeof selectedTree === 'object') { + const tree = selectedTree as TaxonFileDefaultDefinition; + return ( +
+
+
{tree.title}
+
{tree.description}
+
{`${treeText.source()}: ${tree.src}`}
+
+
+ ); + } + return null; + })()} + {isTreeDialogOpen ? ( + { + handleChange(fieldName, resource); + handleChange('preload', true); + handleTreeDialogClose(); + } } - } - /> - ) : null} -
+ /> + ) : null} + + ) : ( + null + ) ) : ( {!inTable && label} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index ba2e8d88aa7..b9a1210b42a 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -312,6 +312,7 @@ export const resources: RA = [ label: setupToolText.preloadTree(), description: setupToolText.preloadTreeDescription(), type: 'boolean', + width: 4, }, ], }, @@ -380,12 +381,14 @@ export const resources: RA = [ label: setupToolText.preloadTree(), description: setupToolText.preloadTreeDescription(), type: 'boolean', + width: 4, }, { name: 'preloadFile', label: setupToolText.treeToPreload(), description: setupToolText.preloadTreeDescription(), type: 'tree', + width: 4, }, ], }, diff --git a/specifyweb/frontend/js_src/lib/localization/setupTool.ts b/specifyweb/frontend/js_src/lib/localization/setupTool.ts index 6a04cd2c02d..28af2ac82ad 100644 --- a/specifyweb/frontend/js_src/lib/localization/setupTool.ts +++ b/specifyweb/frontend/js_src/lib/localization/setupTool.ts @@ -127,13 +127,13 @@ export const setupToolText = createDictionary({ 'en-us': 'Full Name Direction', }, preloadTree: { - 'en-us': 'Pre-load Tree', + 'en-us': 'Populate tree with default records', }, preloadTreeDescription: { 'en-us': 'Download default records for this tree.', }, treeToPreload: { - 'en-us': 'Tree to pre-load:', + 'en-us': 'Tree to download:', }, selectATree: { 'en-us': 'Select a tree', From 32400b376f6ddd8537ffbb70d98897687396d900 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 21:30:28 +0000 Subject: [PATCH 48/52] Lint code with ESLint and Prettier Triggered by a75062b8deb9f38453810ff1542537d89cfd6fd5 on branch refs/heads/issue-7593 --- .../lib/components/SetupTool/SetupForm.tsx | 24 +++++++++---------- .../components/SetupTool/setupResources.ts | 8 ++++++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx index 98da7f6363b..e4efec184af 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -5,22 +5,22 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; +import { setupToolText } from '../../localization/setupTool'; +import { treeText } from '../../localization/tree'; import { userText } from '../../localization/user'; import { type RA } from '../../utils/types'; import { H3 } from '../Atoms'; -import { Input, Label, Select } from '../Atoms/Form'; import { Button } from '../Atoms/Button'; +import { Input, Label, Select } from '../Atoms/Form'; +import { Dialog } from '../Molecules/Dialog'; import { MIN_PASSWORD_LENGTH } from '../Security/SetPassword'; +import type { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; +import { PopulatedTreeList } from '../TreeView/CreateTree'; import type { FieldConfig, ResourceConfig } from './setupResources'; import { FIELD_MAX_LENGTH, resources } from './setupResources'; import type { ResourceFormData } from './types'; -import { PopulatedTreeList } from '../TreeView/CreateTree'; -import type { TaxonFileDefaultDefinition } from '../TreeView/CreateTree'; -import { useBooleanState } from '../../hooks/useBooleanState'; -import { treeText } from '../../localization/tree'; -import { Dialog } from '../Molecules/Dialog'; -import { setupToolText } from '../../localization/setupTool'; function getFormValue( formData: ResourceFormData, @@ -65,7 +65,7 @@ export function renderFormFieldFactory({ readonly currentStep: number; readonly handleChange: ( name: string, - newValue: LocalizedString | boolean | TaxonFileDefaultDefinition + newValue: LocalizedString | TaxonFileDefaultDefinition | boolean ) => void; readonly temporaryFormData: ResourceFormData; readonly setTemporaryFormData: ( @@ -299,10 +299,10 @@ export function renderFormFieldFactory({
{setupToolText.treeRanks()} {subField.label}
{field.label} + {renderFormField( subField, parentName === undefined ? field.name : `${parentName}.${field.name}`, diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts index b9a1210b42a..4ba5d2bb68e 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -29,7 +29,13 @@ type Option = { export type FieldConfig = { readonly name: string; readonly label: string; - readonly type?: 'boolean' | 'object' | 'password' | 'select' | 'text' | 'tree'; + readonly type?: + | 'boolean' + | 'object' + | 'password' + | 'select' + | 'text' + | 'tree'; readonly required?: boolean; readonly default?: boolean | number | string; readonly description?: string; From d27a8dadd461829f4e049c5318c8e492027f01ad Mon Sep 17 00:00:00 2001 From: alesan99 Date: Mon, 26 Jan 2026 15:45:39 -0600 Subject: [PATCH 49/52] Fix tree preview in overview --- .../js_src/lib/components/SetupTool/SetupOverview.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx index 3c7ca9cf8a2..033aeb3b4e6 100644 --- a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx @@ -81,8 +81,10 @@ export function SetupOverview({ (option) => String(option.value) === value ); value = match ? (match.label ?? match.value) : value; - } else if (field.type == 'boolean') { + } else if (field.type === 'boolean') { value = rawValue === true ? queryText.yes() : commonText.no(); + } else if (field.type === 'tree') { + value = typeof rawValue === 'object' ? String(rawValue.title) : '-'; } return (
+ {renderFormField( subField, - parentName === undefined ? field.name : `${parentName}.${field.name}`, + parentName === undefined + ? field.name + : `${parentName}.${field.name}`, true )}