From db8a3651b63e9c65f360e0646b5c48c5354a85e7 Mon Sep 17 00:00:00 2001 From: alesan99 Date: Wed, 14 Jan 2026 09:34:50 -0600 Subject: [PATCH 01/33] 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 02/33] 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 03/33] 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 04/33] 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 05/33] 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 06/33] 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 07/33] 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 08/33] 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 09/33] 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 10/33] 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 11/33] 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 12/33] 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 13/33] 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 14/33] 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 15/33] 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 16/33] 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 17/33] 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 18/33] 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 19/33] 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 20/33] 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 21/33] 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 22/33] 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 23/33] 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 24/33] 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 25/33] 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 26/33] 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 27/33] 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 28/33] 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 29/33] 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 30/33] 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 31/33] 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 32/33] 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 33/33] 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 }) => {