diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx new file mode 100644 index 00000000000..2fbd1182e36 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx @@ -0,0 +1,124 @@ +import React from 'react'; + +import { batchEditText } from '../../localization/batchEdit'; +import { commonText } from '../../localization/common'; +import { interactionsText } from '../../localization/interactions'; +import type { RA, RR } from '../../utils/types'; +import { H2, H3, Ul } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { Input, Label } from '../Atoms/Form'; +import { dialogIcons } from '../Atoms/Icons'; +import type { AnyTree } from '../DataModel/helperTypes'; +import { strictGetTable } from '../DataModel/tables'; +import { getTreeDefinitions } from '../InitialContext/treeRanks'; +import { Dialog } from '../Molecules/Dialog'; +import { TableIcon } from '../Molecules/TableIcon'; + +export type TreeDefinitionName = string; + +export type MissingRanks = { + // Query can contain relationship to multiple trees + readonly [KEY in AnyTree['tableName']]: RR>; +}; + +export function MissingRanksDialog({ + missingRanks, + onSelectTreeDef, + onContinue: handleContinue, + onClose: handleClose, +}: { + readonly missingRanks: MissingRanks; + readonly onSelectTreeDef: ( + treeTableName: AnyTree['tableName'], + treeDefId: number + ) => void; + readonly onContinue: () => void; + readonly onClose: () => void; +}): JSX.Element { + return ( + + {commonText.close()} + + {interactionsText.continue()} + + + } + header={batchEditText.missingRanksInQuery()} + icon={dialogIcons.info} + onClose={handleClose} + > + + + ); +} + +function ShowMissingRanks({ + missingRanks, + onSelectTreeDef: handleSelectTreeDef, +}: { + readonly missingRanks: MissingRanks; + readonly onSelectTreeDef: ( + treeTableName: AnyTree['tableName'], + treeDefId: number + ) => void; +}) { + return ( +
+
+

{batchEditText.addTreeRank()}

+
+ {Object.entries(missingRanks).map(([treeTable, ranks]) => { + const hasMultipleTreeDefs = Object.values(ranks).length > 1; + const treeDefinitions = getTreeDefinitions(treeTable, 'all'); + + return ( +
+
+ +

{strictGetTable(treeTable).label}

+
+ {hasMultipleTreeDefs && ( + {batchEditText.pickTreesToFilter()} + )} +
+ {Object.entries(ranks).map(([treeDefName, rankNames]) => { + const treeDefId = treeDefinitions.find( + ({ definition }) => definition.name === treeDefName + )?.definition.id; + return ( +
+ + {hasMultipleTreeDefs && treeDefId !== undefined ? ( + + handleSelectTreeDef(treeTable, treeDefId) + } + /> + ) : undefined} +

{`${treeDefName}:`}

+
+
    + {rankNames.map((rank) => ( +
  • + {rank} +
  • + ))} +
+
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx new file mode 100644 index 00000000000..4aefa79910d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { batchEditText } from '../../localization/batchEdit'; +import { commonText } from '../../localization/common'; +import type { RA } from '../../utils/types'; +import { H2, H3 } from '../Atoms'; +import { dialogIcons } from '../Atoms/Icons'; +import { Dialog } from '../Molecules/Dialog'; + +export type QueryError = { + readonly invalidFields: RA; +}; + +export function ErrorsDialog({ + errors, + onClose: handleClose, +}: { + readonly errors: QueryError; + readonly onClose: () => void; +}): JSX.Element { + return ( + + + + ); +} + +function ShowInvalidFields({ + error, +}: { + readonly error: QueryError['invalidFields']; +}) { + const hasErrors = error.length > 0; + return hasErrors ? ( +
+
+

{batchEditText.removeField()}

+
+ {error.map((singleError) => ( +

{singleError}

+ ))} +
+ ) : null; +} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 91f82ec71b6..48220f4716f 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -1,15 +1,13 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; +import type { LocalizedString } from 'typesafe-i18n'; import { batchEditText } from '../../localization/batchEdit'; -import { commonText } from '../../localization/common'; import { ajax } from '../../utils/ajax'; import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { keysToLowerCase } from '../../utils/utils'; -import { H2, H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; -import { dialogIcons } from '../Atoms/Icons'; import { LoadingContext } from '../Core/Contexts'; import type { AnyTree } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -17,13 +15,17 @@ import { schema } from '../DataModel/schema'; import { serializeResource } from '../DataModel/serializers'; import type { SpQuery, Tables } from '../DataModel/types'; import { isTreeTable, treeRanksPromise } from '../InitialContext/treeRanks'; -import { Dialog } from '../Molecules/Dialog'; import { userPreferences } from '../Preferences/userPreferences'; import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; import type { QueryField } from '../QueryBuilder/helpers'; import { uniquifyDataSetName } from '../WbImport/helpers'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; +import type { MissingRanks } from './MissingRanks'; +import { MissingRanksDialog } from './MissingRanks'; +import { findAllMissing } from './missingRanksUtils'; +import type { QueryError } from './QueryError'; +import { ErrorsDialog } from './QueryError'; const queryFieldSpecHeader = (queryFieldSpec: QueryFieldSpec) => generateMappingPathPreview( @@ -31,6 +33,13 @@ const queryFieldSpecHeader = (queryFieldSpec: QueryFieldSpec) => queryFieldSpec.toMappingPath() ); +// Data structure for passing the treedefs to use for batch edit in case of missing ranks +type TreeDefsFilter = + | { + readonly [KEY in AnyTree['tableName']]: RA; + } + | {}; + export function BatchEditFromQuery({ query, fields, @@ -58,9 +67,16 @@ export function BatchEditFromQuery({ name: dataSetName, recordSetId, limit: userPreferences.get('batchEdit', 'query', 'limit'), + treeDefsFilter, }), }); - const [errors, setErrors] = React.useState(undefined); + + const [errors, setErrors] = React.useState(); + const [missingRanks, setMissingRanks] = React.useState(); + const [datasetName, setDatasetName] = React.useState(); + const [treeDefsFilter, setTreeDefsFilter] = React.useState( + {} + ); const loading = React.useContext(LoadingContext); const queryFieldSpecs = React.useMemo( @@ -75,32 +91,53 @@ export function BatchEditFromQuery({ [fields] ); + const handleCheckboxChange = ( + treeTableName: AnyTree['tableName'], + treeDefId: number + ) => { + setTreeDefsFilter((previousFilter) => { + const updatedFilter = { ...previousFilter }; + if (Array.isArray(updatedFilter[treeTableName])) { + updatedFilter[treeTableName] = updatedFilter[treeTableName]?.includes( + treeDefId + ) + ? updatedFilter[treeTableName]?.filter((id) => id !== treeDefId) + : updatedFilter[treeTableName]?.concat(treeDefId); + } else { + updatedFilter[treeTableName] = [treeDefId]; + } + return updatedFilter; + }); + }; + + const handleCloseDialog = () => { + setDatasetName(undefined); + setMissingRanks(undefined); + }; + + const handleCreateDataset = async (newName: string) => + uniquifyDataSetName(newName, undefined, 'batchEdit').then(async (name) => + post(name).then(({ data }) => { + handleCloseDialog(); + navigate(`/specify/workbench/${data.id}`); + }) + ); + return ( <> { loading( treeRanksPromise.then(async () => { - // Const missingRanks = findAllMissing(queryFieldSpecs); const invalidFields = queryFieldSpecs.filter((fieldSpec) => filters.some((filter) => filter(fieldSpec)) ); - const hasErrors = - // Object.values(missingRanks).some((ranks) => ranks.length > 0) || - invalidFields.length > 0; + const hasErrors = invalidFields.length > 0; if (hasErrors) { setErrors({ invalidFields: invalidFields.map(queryFieldSpecHeader), @@ -108,16 +145,21 @@ export function BatchEditFromQuery({ return; } + const missingRanks = findAllMissing(queryFieldSpecs); const newName = batchEditText.datasetName({ queryName: query.get('name'), datePart: new Date().toDateString(), }); - return uniquifyDataSetName(newName, undefined, 'batchEdit').then( - async (name) => - post(name).then(({ data }) => - navigate(`/specify/workbench/${data.id}`) - ) + const hasMissingRanks = Object.entries(missingRanks).some( + ([_, rankData]) => Object.values(rankData).length > 0 ); + if (hasMissingRanks) { + setMissingRanks(missingRanks); + setDatasetName(newName); + return; + } + + return handleCreateDataset(newName); }) ); }} @@ -127,18 +169,18 @@ export function BatchEditFromQuery({ {errors !== undefined && ( setErrors(undefined)} /> )} + {missingRanks !== undefined && datasetName !== undefined ? ( + loading(handleCreateDataset(datasetName))} + onSelectTreeDef={handleCheckboxChange} + /> + ) : undefined} ); } -type QueryError = { - readonly missingRanks?: { - // Query can contain relationship to multiple trees - readonly [KEY in AnyTree['tableName']]: RA; - }; - readonly invalidFields: RA; -}; - function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { const joinPath = queryFieldSpec.joinPath; if (joinPath.length <= 1) return false; @@ -146,7 +188,13 @@ function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { (relationship) => relationship.isRelationship && relationshipIsToMany(relationship) ); - return nestedToManyCount.length > 1; + + const isTreeOnlyQuery = + isTreeTable(queryFieldSpec.baseTable.name) && + isTreeTable(queryFieldSpec.table.name); + + const allowedToMany = isTreeOnlyQuery ? 0 : 1; + return nestedToManyCount.length > allowedToMany; } const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => @@ -157,180 +205,5 @@ const hasHierarchyBaseTable = (queryFieldSpec: QueryFieldSpec) => queryFieldSpec.baseTable.name.toLowerCase() as 'collection' ); -const containsTreeTableOrSpecificRank = (queryFieldSpec: QueryFieldSpec) => - isTreeTable(queryFieldSpec.baseTable.name); - +// Error filters const filters = [containsFaultyNestedToMany, containsSystemTables]; - -/* - * Const getTreeDefFromName = ( - * rankName: string, - * treeDefItems: RA>> - * ) => - * defined( - * treeDefItems.find( - * (treeRank) => treeRank.name.toLowerCase() === rankName.toLowerCase() - * ) - * ); - */ - -/* - * Function findAllMissing( - * queryFieldSpecs: RA - * ): QueryError['missingRanks'] { - * const treeFieldSpecs = group( - * filterArray( - * queryFieldSpecs.map((fieldSpec) => - * isTreeTable(fieldSpec.table.name) && - * fieldSpec.treeRank !== anyTreeRank && - * fieldSpec.treeRank !== undefined - * ? [ - * fieldSpec.table, - * { rank: fieldSpec.treeRank, field: fieldSpec.getField() }, - * ] - * : undefined - * ) - * ) - * ); - */ - -/* - * Return Object.fromEntries( - * treeFieldSpecs.map(([treeTable, treeRanks]) => [ - * treeTable.name, - * findMissingRanks(treeTable, treeRanks), - * ]) - * ); - * } - */ - -/* - * TODO: discuss if we need to add more of them, and if we need to add more of them for other table. - * const requiredTreeFields: RA = ['name'] as const; - */ - -/* - * Function findMissingRanks( - * treeTable: SpecifyTable, - * treeRanks: RA< - * | { readonly rank: string; readonly field?: LiteralField | Relationship } - * | undefined - * > - * ): RA { - * const allTreeDefItems = strictGetTreeDefinitionItems( - * treeTable.name as 'Geography', - * false - * ); - */ - -/* - * // Duplicates don't affect any logic here - * const currentTreeRanks = filterArray( - * treeRanks.map((treeRank) => - * f.maybe(treeRank, ({ rank, field }) => ({ - * specifyRank: getTreeDefFromName(rank, allTreeDefItems), - * field, - * })) - * ) - * ); - */ - -/* - * Const currentRanksSorted = Array.from(currentTreeRanks).sort( - * sortFunction(({ specifyRank: { rankId } }) => rankId) - * ); - */ - -// Const highestRank = currentRanksSorted[0]; - -/* - * Return allTreeDefItems.flatMap(({ rankId, name }) => - * rankId < highestRank.specifyRank.rankId - * ? [] - * : filterArray( - * requiredTreeFields.map((requiredField) => - * currentTreeRanks.some( - * (rank) => - * rank.specifyRank.name === name && - * rank.field !== undefined && - * requiredField === rank.field.name - * ) - * ? undefined - * : `${name} ${ - * defined( - * strictGetTable(treeTable.name).getField(requiredField) - * ).label - * }` - * ) - * ) - * ); - * } - */ - -function ErrorsDialog({ - errors, - onClose: handleClose, -}: { - readonly errors: QueryError; - readonly onClose: () => void; -}): JSX.Element { - return ( - - - {/* */} - - ); -} - -function ShowInvalidFields({ - error, -}: { - readonly error: QueryError['invalidFields']; -}) { - const hasErrors = error.length > 0; - return hasErrors ? ( -
-
-

{batchEditText.removeField()}

-
- {error.map((singleError) => ( -

{singleError}

- ))} -
- ) : null; -} - -/* - * Function ShowMissingRanks({ - * error, - * }: { - * readonly error: QueryError['missingRanks']; - * }) { - * const hasMissing = Object.values(error).some((rank) => rank.length > 0); - * return hasMissing ? ( - *
- *
- *

{batchEditText.addTreeRank()}

- *
- * {Object.entries(error).map(([treeTable, ranks]) => ( - *
- *
- * - *

{strictGetTable(treeTable).label}

- *
- *
- * {ranks.map((rank) => ( - *

{rank}

- * ))} - *
- *
- * ))} - *
- * ) : null; - * } - */ diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts b/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts new file mode 100644 index 00000000000..dd3fc0a7144 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts @@ -0,0 +1,150 @@ +import { f } from '../../utils/functools'; +import type { RA, RR } from '../../utils/types'; +import { defined, filterArray } from '../../utils/types'; +import { group, sortFunction } from '../../utils/utils'; +import type { + AnyTree, + FilterTablesByEndsWith, + SerializedResource, +} from '../DataModel/helperTypes'; +import type { LiteralField, Relationship } from '../DataModel/specifyField'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { strictGetTable } from '../DataModel/tables'; +import { + getTreeDefinitions, + isTreeTable, + strictGetTreeDefinitionItems, +} from '../InitialContext/treeRanks'; +import type { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import { anyTreeRank } from '../WbPlanView/mappingHelpers'; +import type { MissingRanks, TreeDefinitionName } from './MissingRanks'; + +const getTreeDefFromName = ( + rankName: string, + treeDefItems: RA>> +) => + defined( + treeDefItems.find( + (treeRank) => treeRank.name.toLowerCase() === rankName.toLowerCase() + ) + ); + +export function findAllMissing( + queryFieldSpecs: RA +): MissingRanks { + const treeFieldSpecs = group( + filterArray( + queryFieldSpecs.map((fieldSpec) => + isTreeTable(fieldSpec.table.name) && + fieldSpec.treeRank !== anyTreeRank && + fieldSpec.treeRank !== undefined + ? [ + fieldSpec.table, + { rank: fieldSpec.treeRank, field: fieldSpec.getField() }, + ] + : undefined + ) + ) + ); + + return Object.fromEntries( + treeFieldSpecs + .map(([treeTable, treeRanks]) => [ + treeTable.name, + findMissingRanks(treeTable, treeRanks), + ]) + .filter(([_, rankData]) => Object.values(rankData).length > 0) + ); +} + +// TODO: discuss if we need to add more of them, and if we need to add more of them for other table. +const requiredTreeFields: RA = ['name'] as const; + +const nameExistsInRanks = ( + name: string, + ranks: RA>> +): boolean => ranks.some((rank) => rank.name === name); + +function findMissingRanks( + treeTable: SpecifyTable, + treeRanks: RA< + | { readonly rank: string; readonly field?: LiteralField | Relationship } + | undefined + > +): RR> { + const allTreeDefItems = strictGetTreeDefinitionItems( + treeTable.name as 'Geography', + false, + 'all' + ); + + // Duplicates don't affect any logic here + const currentTreeRanks = filterArray( + treeRanks.map((treeRank) => + f.maybe(treeRank, ({ rank, field }) => ({ + specifyRank: getTreeDefFromName(rank, allTreeDefItems), + field, + })) + ) + ); + + const currentRanksSorted = Array.from(currentTreeRanks).sort( + sortFunction(({ specifyRank: { rankId } }) => rankId) + ); + + const highestRank = currentRanksSorted[0]; + + const treeDefinitions = getTreeDefinitions( + treeTable.name as 'Geography', + 'all' + ); + + return Object.fromEntries( + treeDefinitions + .map(({ definition, ranks }) => [ + definition.name, + findMissingRanksInTreeDefItems( + ranks, + treeTable.name, + highestRank, + currentTreeRanks + ), + ]) + .filter(([_, missingRanks]) => missingRanks.length > 0) + ); +} + +type RankData = { + readonly specifyRank: SerializedResource< + FilterTablesByEndsWith<'TreeDefItem'> + >; + readonly field: LiteralField | Relationship | undefined; +}; + +const findMissingRanksInTreeDefItems = ( + treeDefItems: RA>>, + tableName: string, + highestRank: RankData, + currentTreeRanks: RA +): RA => + treeDefItems.flatMap(({ treeDef, rankId, name }) => + rankId < highestRank.specifyRank.rankId + ? [] + : filterArray( + requiredTreeFields.map((requiredField) => + currentTreeRanks.some( + (rank) => + (rank.specifyRank.name === name && + rank.field !== undefined && + requiredField === rank.field.name && + rank.specifyRank.treeDef === treeDef) || + !nameExistsInRanks(rank.specifyRank.name, treeDefItems) + ) + ? undefined + : `${name} - ${ + defined(strictGetTable(tableName).getField(requiredField)) + .label + }` + ) + ) + ); diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index f6487ecf342..196d7d4214d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -20,8 +20,8 @@ theories(getMappingLineData, [ ], out: [ { - defaultValue: 'determinations', customSelectSubtype: 'simple', + defaultValue: 'determinations', selectLabel: localized('Collection Object'), fieldsData: { catalogNumber: { @@ -74,15 +74,6 @@ theories(getMappingLineData, [ isDefault: false, isRelationship: false, }, - leftSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Left Side Rels', - tableName: 'CollectionRelationship', - }, altCatalogNumber: { optionLabel: 'Prev/Exch #', isEnabled: true, @@ -115,15 +106,6 @@ theories(getMappingLineData, [ isDefault: false, isRelationship: false, }, - rightSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Right Side Rels', - tableName: 'CollectionRelationship', - }, fieldNumber: { optionLabel: 'Voucher', isEnabled: true, @@ -168,6 +150,24 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'CollectionObjectAttribute', }, + collection: { + optionLabel: 'Collection', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'Collection', + }, + collectionObjectAttachments: { + optionLabel: 'Collection Object Attachments', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionObjectAttachment', + }, collectionObjectCitations: { optionLabel: 'Collection Object Citations', isEnabled: true, @@ -213,23 +213,14 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'CollectingEvent', }, - collection: { - isDefault: false, + leftSideRels: { + optionLabel: 'Left Side Rels', isEnabled: true, - isHidden: false, - isRelationship: true, isRequired: false, - optionLabel: 'Collection', - tableName: 'Collection', - }, - collectionObjectAttachments: { - isDefault: false, - isEnabled: true, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object Attachments', - tableName: 'CollectionObjectAttachment', + tableName: 'CollectionRelationship', }, preparations: { optionLabel: 'Preparations', @@ -240,6 +231,15 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'Preparation', }, + rightSideRels: { + optionLabel: 'Right Side Rels', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionRelationship', + }, voucherRelationships: { optionLabel: 'Voucher Relationships', isEnabled: true, @@ -253,8 +253,8 @@ theories(getMappingLineData, [ tableName: 'CollectionObject', }, { - defaultValue: '#1', customSelectSubtype: 'toMany', + defaultValue: '#1', selectLabel: localized('Determination'), fieldsData: { '#1': { @@ -273,8 +273,8 @@ theories(getMappingLineData, [ tableName: 'Determination', }, { - defaultValue: 'taxon', customSelectSubtype: 'simple', + defaultValue: 'taxon', selectLabel: localized('Determination'), fieldsData: { determinedDate: { @@ -310,6 +310,15 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'Agent', }, + determiners: { + optionLabel: 'Determiners', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'Determiner', + }, taxon: { optionLabel: 'Taxon', isEnabled: true, @@ -323,8 +332,8 @@ theories(getMappingLineData, [ tableName: 'Determination', }, { - defaultValue: '$Family', customSelectSubtype: 'tree', + defaultValue: '$Family', selectLabel: localized('Taxon'), fieldsData: { $Kingdom: { @@ -391,8 +400,8 @@ theories(getMappingLineData, [ tableName: 'Taxon', }, { - defaultValue: 'name', customSelectSubtype: 'simple', + defaultValue: 'name', selectLabel: localized('Taxon'), fieldsData: { author: { @@ -462,13 +471,14 @@ theories(getMappingLineData, [ { customSelectSubtype: 'simple', defaultValue: 'determinations', + selectLabel: localized('Collection Object'), fieldsData: { '-formatted': { - isDefault: false, - isEnabled: true, - isRelationship: false, optionLabel: '(formatted)', tableName: 'CollectionObject', + isRelationship: false, + isDefault: false, + isEnabled: true, }, absoluteAges: { isDefault: false, @@ -479,71 +489,142 @@ theories(getMappingLineData, [ optionLabel: 'Absolute Ages', tableName: 'AbsoluteAge', }, - accession: { - isDefault: false, + catalogNumber: { + optionLabel: 'Cat #', isEnabled: true, - isHidden: false, - isRelationship: true, isRequired: false, - optionLabel: 'Accession #', - tableName: 'Accession', - }, - altCatalogNumber: { + isHidden: false, isDefault: false, + isRelationship: false, + }, + 'catalogedDate-fullDate': { + optionLabel: 'Cat Date', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Prev/Exch #', }, - catalogNumber: { - isDefault: false, + 'catalogedDate-day': { + optionLabel: 'Cat Date (Day)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Cat #', }, - 'catalogedDate-day': { + 'catalogedDate-month': { + optionLabel: 'Cat Date (Month)', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + 'catalogedDate-year': { + optionLabel: 'Cat Date (Year)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + reservedText: { + optionLabel: 'CT Scan', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date (Day)', + isHidden: false, + isDefault: false, + isRelationship: false, }, - 'catalogedDate-fullDate': { + 'timestampModified-fullDate': { + optionLabel: 'Date Edited', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + 'timestampModified-day': { + optionLabel: 'Date Edited (Day)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + 'timestampModified-month': { + optionLabel: 'Date Edited (Month)', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date', + isHidden: false, + isDefault: false, + isRelationship: false, }, - 'catalogedDate-month': { + 'timestampModified-year': { + optionLabel: 'Date Edited (Year)', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + guid: { + optionLabel: 'GUID', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + altCatalogNumber: { + optionLabel: 'Prev/Exch #', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date (Month)', + isHidden: false, + isDefault: false, + isRelationship: false, }, - 'catalogedDate-year': { + projectNumber: { + optionLabel: 'Project Number', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + remarks: { + optionLabel: 'Remarks', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + reservedText2: { + optionLabel: 'Reserved Text2', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date (Year)', + isHidden: false, + isDefault: false, + isRelationship: false, }, - cataloger: { + fieldNumber: { + optionLabel: 'Voucher', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + accession: { + optionLabel: 'Accession #', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Cataloger', - tableName: 'Agent', + tableName: 'Accession', }, cojo: { isDefault: false, @@ -554,49 +635,49 @@ theories(getMappingLineData, [ optionLabel: 'Cojo', tableName: 'CollectionObjectGroupJoin', }, - collectingEvent: { - isDefault: false, + cataloger: { + optionLabel: 'Cataloger', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, + tableName: 'Agent', + }, + collectionObjectAttribute: { + optionLabel: 'Col Obj Attribute', + isEnabled: true, isRequired: false, - optionLabel: 'Field No: Locality', - tableName: 'CollectingEvent', + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionObjectAttribute', }, collection: { - isDefault: false, + optionLabel: 'Collection', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Collection', tableName: 'Collection', }, collectionObjectAttachments: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, optionLabel: 'Collection Object Attachments', - tableName: 'CollectionObjectAttachment', - }, - collectionObjectAttribute: { - isDefault: false, isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Col Obj Attribute', - tableName: 'CollectionObjectAttribute', + tableName: 'CollectionObjectAttachment', }, collectionObjectCitations: { - isDefault: false, + optionLabel: 'Collection Object Citations', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object Citations', tableName: 'CollectionObjectCitation', }, collectionObjectType: { @@ -609,73 +690,40 @@ theories(getMappingLineData, [ tableName: 'CollectionObjectType', }, determinations: { - isDefault: true, + optionLabel: 'Determinations', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: true, isRelationship: true, - isRequired: false, - optionLabel: 'Determinations', tableName: 'Determination', }, dnaSequences: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, optionLabel: 'DNA Sequences', - tableName: 'DNASequence', - }, - fieldNumber: { - isDefault: false, isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'Voucher', - }, - guid: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'GUID', - }, - leftSideRels: { isDefault: false, - isEnabled: true, - isHidden: false, isRelationship: true, - isRequired: false, - optionLabel: 'Left Side Rels', - tableName: 'CollectionRelationship', + tableName: 'DNASequence', }, modifiedByAgent: { - isDefault: false, + optionLabel: 'Edited By', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Edited By', tableName: 'Agent', }, - preparations: { - isDefault: false, + collectingEvent: { + optionLabel: 'Field No: Locality', isEnabled: true, - isHidden: false, - isRelationship: true, isRequired: false, - optionLabel: 'Preparations', - tableName: 'Preparation', - }, - projectNumber: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Project Number', + isDefault: false, + isRelationship: true, + tableName: 'CollectingEvent', }, relativeAges: { isDefault: false, @@ -686,297 +734,313 @@ theories(getMappingLineData, [ optionLabel: 'Relative Ages', tableName: 'RelativeAge', }, - remarks: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Remarks', - }, - reservedText: { - isDefault: false, + leftSideRels: { + optionLabel: 'Left Side Rels', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'CT Scan', - }, - reservedText2: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Reserved Text2', - }, - rightSideRels: { isDefault: false, - isEnabled: true, - isHidden: false, isRelationship: true, - isRequired: false, - optionLabel: 'Right Side Rels', tableName: 'CollectionRelationship', }, - 'timestampModified-day': { - isDefault: false, + preparations: { + optionLabel: 'Preparations', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'Date Edited (Day)', - }, - 'timestampModified-fullDate': { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited', - }, - 'timestampModified-month': { isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited (Month)', + isRelationship: true, + tableName: 'Preparation', }, - 'timestampModified-year': { - isDefault: false, + rightSideRels: { + optionLabel: 'Right Side Rels', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'Date Edited (Year)', + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionRelationship', }, voucherRelationships: { - isDefault: false, + optionLabel: 'Voucher Relationships', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Voucher Relationships', tableName: 'VoucherRelationship', }, }, - selectLabel: localized('Collection Object'), tableName: 'CollectionObject', }, { customSelectSubtype: 'simple', defaultValue: 'taxon', + selectLabel: localized('Determination'), fieldsData: { '-formatted': { - isDefault: false, - isEnabled: true, - isRelationship: false, optionLabel: '(aggregated)', tableName: 'Determination', - }, - collectionObject: { + isRelationship: false, isDefault: false, isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object', - tableName: 'CollectionObject', }, - 'determinedDate-day': { - isDefault: false, + isCurrent: { + optionLabel: 'Current', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Date (Day)', }, 'determinedDate-fullDate': { - isDefault: false, + optionLabel: 'Date', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + 'determinedDate-day': { + optionLabel: 'Date (Day)', + isEnabled: true, isRequired: false, - optionLabel: 'Date', + isHidden: false, + isDefault: false, + isRelationship: false, }, 'determinedDate-month': { - isDefault: false, + optionLabel: 'Date (Month)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Date (Month)', }, 'determinedDate-year': { + optionLabel: 'Date (Year)', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + guid: { + optionLabel: 'GUID', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + typeStatusName: { + optionLabel: 'Type Status', + isEnabled: true, isRequired: false, - optionLabel: 'Date (Year)', + isHidden: false, + isDefault: false, + isRelationship: false, }, determiner: { - isDefault: false, + optionLabel: 'Determiner', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Determiner', tableName: 'Agent', }, determiners: { - isDefault: false, + optionLabel: 'Determiners', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Determiners', tableName: 'Determiner', }, - guid: { - isDefault: false, + preferredTaxon: { + optionLabel: 'Preferred Taxon', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'GUID', - }, - isCurrent: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Current', - }, - preferredTaxon: { isDefault: false, - isEnabled: true, - isHidden: false, isRelationship: true, - isRequired: false, - optionLabel: 'Preferred Taxon', tableName: 'Taxon', }, taxon: { - isDefault: true, + optionLabel: 'Taxon', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: true, isRelationship: true, - isRequired: false, - optionLabel: 'Taxon', tableName: 'Taxon', }, - typeStatusName: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Type Status', - }, }, - selectLabel: localized('Determination'), tableName: 'Determination', }, { customSelectSubtype: 'tree', defaultValue: '$Family', + selectLabel: localized('Taxon'), fieldsData: { '$-any': { + optionLabel: '(any rank)', + isRelationship: true, isDefault: false, isEnabled: true, - isRelationship: true, - optionLabel: '(any rank)', tableName: 'Taxon', }, - $Class: { - isDefault: false, + $Kingdom: { + optionLabel: 'Kingdom', isRelationship: true, - optionLabel: 'Class', + isDefault: false, tableName: 'Taxon', }, - $Family: { - isDefault: true, + $Phylum: { + optionLabel: 'Phylum', isRelationship: true, - optionLabel: 'Family', - tableName: 'Taxon', - }, - $Genus: { isDefault: false, - isRelationship: true, - optionLabel: 'Genus', tableName: 'Taxon', }, - $Kingdom: { - isDefault: false, + $Class: { + optionLabel: 'Class', isRelationship: true, - optionLabel: 'Kingdom', + isDefault: false, tableName: 'Taxon', }, $Order: { - isDefault: false, - isRelationship: true, optionLabel: 'Order', - tableName: 'Taxon', - }, - $Phylum: { - isDefault: false, isRelationship: true, - optionLabel: 'Phylum', + isDefault: false, tableName: 'Taxon', }, - $Species: { - isDefault: false, + $Family: { + optionLabel: 'Family', isRelationship: true, - optionLabel: 'Species', + isDefault: true, tableName: 'Taxon', }, $Subfamily: { + optionLabel: 'Subfamily', + isRelationship: true, isDefault: false, + tableName: 'Taxon', + }, + $Genus: { + optionLabel: 'Genus', isRelationship: true, - optionLabel: 'Subfamily', + isDefault: false, tableName: 'Taxon', }, $Subgenus: { + optionLabel: 'Subgenus', + isRelationship: true, isDefault: false, + tableName: 'Taxon', + }, + $Species: { + optionLabel: 'Species', isRelationship: true, - optionLabel: 'Subgenus', + isDefault: false, tableName: 'Taxon', }, $Subspecies: { - isDefault: false, - isRelationship: true, optionLabel: 'Subspecies', + isRelationship: true, + isDefault: false, tableName: 'Taxon', }, }, - selectLabel: localized('Taxon'), tableName: 'Taxon', }, { customSelectSubtype: 'simple', defaultValue: 'name', + selectLabel: localized('Taxon'), fieldsData: { author: { + optionLabel: 'Author', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + fullName: { + optionLabel: 'Full Name', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + commonName: { + optionLabel: 'Common Name', + isEnabled: true, isRequired: false, - optionLabel: 'Author', + isHidden: false, + isDefault: false, + isRelationship: false, }, - fullName: { + guid: { + optionLabel: 'GUID', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: false, + }, + isAccepted: { isDefault: false, isEnabled: true, isHidden: false, isRelationship: false, isRequired: false, - optionLabel: 'Full Name', + optionLabel: 'Is Preferred', + }, + isHybrid: { + optionLabel: 'Is Hybrid', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: false, + }, + name: { + optionLabel: 'Name', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: true, + isRelationship: false, + }, + rankId: { + optionLabel: 'Rank ID', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: false, + }, + remarks: { + optionLabel: 'Remarks', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: false, + }, + source: { + optionLabel: 'Source', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: false, }, }, - selectLabel: localized('Taxon'), tableName: 'Taxon', }, ], diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 1dd5a3291a1..d5a6fcd3ce4 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -206,13 +206,6 @@ export type MappingLineData = Pick< readonly defaultValue: string; }; -const queryBuilderTreeFields = new Set([ - 'fullName', - 'author', - 'groupNumber', - 'geographyCode', -]); - /** * Get data required to build a mapping line from a source mapping path * Handles circular dependencies and must match tables @@ -484,8 +477,7 @@ export function getMappingLineData({ ((generateFieldData === 'all' && (!isTreeTable(table.name) || mappingPath[internalState.position - 1] === - formatTreeRank(anyTreeRank) || - queryBuilderTreeFields.has(formattedEntry))) || + formatTreeRank(anyTreeRank))) || internalState.defaultValue === formattedEntry) ? ([ formattedEntry, @@ -573,13 +565,6 @@ export function getMappingLineData({ spec.includeReadOnly || !field.overrides.isReadOnly; - isIncluded &&= - spec.includeAllTreeFields || - !isTreeTable(table.name) || - mappingPath[internalState.position - 1] === - formatTreeRank(anyTreeRank) || - queryBuilderTreeFields.has(field.name); - // Hide frontend only field isIncluded &&= !( getFrontEndOnlyFields()[table.name]?.includes(field.name) === @@ -588,15 +573,15 @@ export function getMappingLineData({ if (field.isRelationship) { isIncluded &&= - spec.allowNestedToMany || parentRelationship === undefined || (!isCircularRelationship(parentRelationship, field) && - !( - (relationshipIsToMany(field) || - relationshipIsRemoteToOne(field)) && - (relationshipIsToMany(parentRelationship) || - relationshipIsRemoteToOne(parentRelationship)) - )); + (spec.allowNestedToMany || + !( + (relationshipIsToMany(field) || + relationshipIsRemoteToOne(field)) && + (relationshipIsToMany(parentRelationship) || + relationshipIsRemoteToOne(parentRelationship)) + ))); isIncluded &&= !canDoAction || @@ -609,7 +594,10 @@ export function getMappingLineData({ )); isIncluded &&= - spec.includeRelationshipsFromTree || !isTreeTable(table.name); + (spec.includeRelationshipsFromTree && + mappingPath[internalState.position - 1] === + formatTreeRank(anyTreeRank)) || + !isTreeTable(table.name); isIncluded &&= spec.includeToManyToTree || diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts index 86c639f5167..5b3e098584f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts @@ -56,7 +56,7 @@ const wbPlanView: NavigatorSpec = { * Hide nested -to-many relationships as they are not * supported by the WorkBench */ - allowNestedToMany: false, + allowNestedToMany: true, ensurePermission: () => userPreferences.get('workBench', 'wbPlanView', 'showNoAccessTables') ? 'create' @@ -96,7 +96,7 @@ const queryBuilder: NavigatorSpec = { allowTransientToMany: true, useSchemaOverrides: false, // All tree fields are only available for "any rank" - includeAllTreeFields: false, + includeAllTreeFields: true, allowNestedToMany: true, ensurePermission: () => userPreferences.get('queryBuilder', 'general', 'showNoReadTables') diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts index 8be421c3cd2..636526249f6 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts @@ -1,5 +1,5 @@ import type { IR, RA, RR } from '../../utils/types'; -import { group, removeKey, split, toLowerCase } from '../../utils/utils'; +import { group, split, toLowerCase } from '../../utils/utils'; import type { AnyTree } from '../DataModel/helperTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; @@ -143,13 +143,10 @@ function toUploadTable( [ fieldName.toLowerCase(), indexMappings(lines).map(([_index, lines]) => - removeKey( - toUploadTable( - table.strictGetRelationship(fieldName).relatedTable, - lines, - mustMatchPreferences - ), - 'toMany' + toUploadTable( + table.strictGetRelationship(fieldName).relatedTable, + lines, + mustMatchPreferences ) ), ] as const diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts index 5d354807723..6126608713a 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts @@ -1,4 +1,4 @@ -import type { IR, RA, RR } from '../../utils/types'; +import type { IR, PartialBy, RA, RR } from '../../utils/types'; import type { AnyTree } from '../DataModel/helperTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; @@ -24,7 +24,11 @@ export type ColumnDefinition = | string | (ColumnOptions & { readonly column: string }); -export type NestedUploadTable = Omit; +/* + * NOTE: This comment was added after workbench supports nested-to-manys. + * Type is made Partial to not chock on legacy upload plans + */ +export type NestedUploadTable = PartialBy; export type UploadTable = { readonly wbcols: IR; @@ -147,23 +151,21 @@ const parseUploadTable = ( [...mappingPath, table.strictGetRelationship(relationshipName).name] ) ), - ...('toMany' in uploadPlan - ? Object.entries(uploadPlan.toMany).flatMap( - ([relationshipName, mappings]) => - Object.values(mappings).flatMap((mapping, index) => - parseUploadTable( - table.strictGetRelationship(relationshipName).relatedTable, - mapping, - makeMustMatch, - [ - ...mappingPath, - table.strictGetRelationship(relationshipName).name, - formatToManyIndex(index + 1), - ] - ) - ) + ...Object.entries(uploadPlan.toMany ?? []).flatMap( + ([relationshipName, mappings]) => + Object.values(mappings).flatMap((mapping, index) => + parseUploadTable( + table.strictGetRelationship(relationshipName).relatedTable, + mapping, + makeMustMatch, + [ + ...mappingPath, + table.strictGetRelationship(relationshipName).name, + formatToManyIndex(index + 1), + ] + ) ) - : []), + ), ]; function parseUploadTableTypes( diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx index f14bd6d1090..d9d57c0dabf 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx @@ -143,11 +143,14 @@ function WbSpreadsheetComponent({ if (isReadOnly) return true; // Or if called on the last row const selectedRegions = getSelectedRegions(hot); - return ( - selectedRegions.length === 1 && - selectedRegions[0].startRow === data.length - 1 && - selectedRegions[0].startRow === selectedRegions[0].endRow - ); + // Allow removing last row in Batch Edit since rows cannot be added in Batch Edit + const disableRemoveLastRow = dataset.isupdate + ? false + : selectedRegions[0].startRow === data.length - 1 && + selectedRegions[0].startRow === + selectedRegions[0].endRow; + + return selectedRegions.length === 1 && disableRemoveLastRow; }, }, disambiguate: { diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 79e083c4297..d8a75b4fd45 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -18,7 +18,12 @@ export const batchEditText = createDictionary({ 'Field not supported for batch edit. Either remove the field, or make it hidden.', }, addTreeRank: { - 'en-us': 'Please add the following missing rank to the query', + 'en-us': + 'The following ranks will be added to the query to enable batch editing', + }, + pickTreesToFilter: { + 'en-us': + 'The selected rank(s) are found in multiple trees. Pick tree(s) to batch edit with', }, datasetName: { 'en-us': '{queryName:string} {datePart:string}', @@ -26,6 +31,9 @@ export const batchEditText = createDictionary({ errorInQuery: { 'en-us': 'Following errors were found in the query', }, + missingRanksInQuery: { + 'en-us': 'Query requires additional ranks for batch editing', + }, createUpdateDataSetInstructions: { 'en-us': 'Use the query builder to make a new batch edit dataset', }, @@ -80,7 +88,4 @@ export const batchEditText = createDictionary({ batchEditRecordSetName: { 'en-us': 'BE commit of "{dataSet:string}"', }, - treeQueriesDisabled: { - 'en-us': 'Batch editing is disabled for trees', - }, } as const); diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json b/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json index f0cc682997a..ea0336746b4 100644 --- a/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json +++ b/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json @@ -160,7 +160,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -179,7 +180,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -198,7 +200,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -217,7 +220,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } @@ -246,7 +250,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } @@ -328,7 +333,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": { @@ -346,7 +352,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ], "preparations": [ @@ -364,7 +371,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } diff --git a/specifyweb/specify/func.py b/specifyweb/specify/func.py index 4fd31f947f9..497d0c42dcd 100644 --- a/specifyweb/specify/func.py +++ b/specifyweb/specify/func.py @@ -18,6 +18,10 @@ def maybe(value: Optional[I], callback: Callable[[I], O]): @staticmethod def sort_by_key(to_sort: Dict[I, O], reverse=False) -> List[Tuple[I, O]]: return sorted(to_sort.items(), key=lambda t: t[0], reverse=reverse) + + @staticmethod + def obj_to_list(obj: Dict[I, O]) -> List[Tuple[I, O]]: + return [(key, val) for key, val in obj.items()] @staticmethod def make_ors(eprns: List[Q]) -> Q: @@ -49,6 +53,10 @@ def first(source: List[Tuple[I, O]]) -> List[I]: @staticmethod def second(source: List[Tuple[I, O]]) -> List[O]: return [second for (_, second) in source] + + @staticmethod + def filter_list(source: List[Optional[I]]) -> List[I]: + return [item for item in source if item is not None] class CustomRepr: def __init__(self, func, new_repr): diff --git a/specifyweb/specify/tests/test_api.py b/specifyweb/specify/tests/test_api.py index d5e3d51ba89..e13f62ab01b 100644 --- a/specifyweb/specify/tests/test_api.py +++ b/specifyweb/specify/tests/test_api.py @@ -77,6 +77,7 @@ def setUp(self): geographytreedef=self.geographytreedef, division=self.division, datatype=self.datatype, + type='paleobotany' ) apply_default_uniqueness_rules(self.discipline) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 9264849f463..407054562c0 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -1,6 +1,6 @@ from functools import wraps from django import http -from typing import Literal, Tuple +from typing import Literal, Tuple, TypedDict, Any, Dict, List from django.db import connection, transaction from django.db.models import F, Q from django.http import HttpResponse @@ -511,11 +511,21 @@ def tree_rank_item_count(request, tree, rankid): @login_maybe_required @require_GET def all_tree_information(request): + result = get_all_tree_information(request.specify_collection, request.specify_user.id) + return HttpResponse(toJson(result), content_type="application/json") + +class TREE_INFORMATION(TypedDict): + # TODO: Stricten all this. + definition: Dict[Any, Any] + ranks: List[Dict[Any, Any]] + +# This is done to make tree fetching easier. +def get_all_tree_information(collection, user_id) -> Dict[str, List[TREE_INFORMATION]]: def has_tree_read_permission(tree: TREE_TABLE) -> bool: return has_table_permission( - request.specify_collection.id, request.specify_user.id, tree, 'read') + collection.id, user_id, tree, 'read') - is_paleo_or_geo_discipline = request.specify_collection.discipline.is_paleo_geo() + is_paleo_or_geo_discipline = collection.discipline.is_paleo_geo() accessible_trees = tuple(filter( has_tree_read_permission, ALL_TREES if is_paleo_or_geo_discipline else COMMON_TREES)) @@ -526,7 +536,7 @@ def has_tree_read_permission(tree: TREE_TABLE) -> bool: result[tree] = [] treedef_model = getattr(spmodels, f'{tree.lower().capitalize()}treedef') - tree_defs = treedef_model.objects.filter(get_search_filters(request.specify_collection, tree)).distinct() + tree_defs = treedef_model.objects.filter(get_search_filters(collection, tree)).distinct() for definition in tree_defs: ranks = definition.treedefitems.order_by('rankid') result[tree].append({ @@ -534,7 +544,7 @@ def has_tree_read_permission(tree: TREE_TABLE) -> bool: 'ranks': [obj_to_data(rank) for rank in ranks] }) - return HttpResponse(toJson(result), content_type='application/json') + return result class TaxonMutationPT(PermissionTarget): resource = "/tree/edit/taxon" @@ -597,4 +607,4 @@ def perm_target(tree): 'geologictimeperiod': GeologictimeperiodMutationPT, 'lithostrat': LithostratMutationPT, 'tectonicunit':TectonicunitMutationPT - }[tree] + }[tree] \ No newline at end of file diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 98a99b7259f..84f5eb5e52d 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -22,6 +22,7 @@ from specifyweb.specify.models import datamodel from specifyweb.specify.load_datamodel import Field, Relationship, Table from specifyweb.specify.datamodel import is_tree_table +from specifyweb.specify.tree_views import get_all_tree_information, TREE_INFORMATION from specifyweb.stored_queries.execution import execute from specifyweb.stored_queries.queryfield import QueryField, fields_from_json from specifyweb.stored_queries.queryfieldspec import ( @@ -31,7 +32,7 @@ ) from specifyweb.workbench.models import Spdataset from specifyweb.workbench.permissions import BatchEditDataSetPT -from specifyweb.workbench.upload.treerecord import TreeRecord +from specifyweb.workbench.upload.treerecord import TreeRecord, TreeRankRecord, RANK_KEY_DELIMITER from specifyweb.workbench.upload.upload_plan_schema import parse_column_options from specifyweb.workbench.upload.upload_table import UploadTable from specifyweb.workbench.upload.uploadable import NULL_RECORD, Uploadable @@ -53,14 +54,15 @@ # - does generation of upload plan in the backend bc upload plan is not known (we don't know count of to-many). # - seemed complicated to merge upload plan from the frontend # - need to place id markers at correct level, so need to follow upload plan anyways. +# REFACTOR: Break this file into smaller pieaces # TODO: Play-around with localizing -NULL_RECORD_DESCRIPTION = "(Not included in the query results)" +BATCH_EDIT_NULL_RECORD_DESCRIPTION = "(Not included in the query results)" # TODO: add backend support for making system tables readonly -READONLY_TABLES = [*CONCRETE_HIERARCHY] +BATCH_EDIT_READONLY_TABLES = [*CONCRETE_HIERARCHY] -SHARED_READONLY_FIELDS = [ +BATCH_EDIT_SHARED_READONLY_FIELDS = [ "timestampcreated", "timestampmodified", "version", @@ -68,25 +70,27 @@ "highestchildnodenumber", "rankid", "fullname", - "age" + "age", ] -SHARED_READONLY_RELATIONSHIPS = ["createdbyagent", "modifiedbyagent"] +BATCH_EDIT_SHARED_READONLY_RELATIONSHIPS = ["createdbyagent", "modifiedbyagent"] + +BATCH_EDIT_REQUIRED_TREE_FIELDS = ["name"] def get_readonly_fields(table: Table): - fields = [*SHARED_READONLY_FIELDS, table.idFieldName.lower()] + fields = [*BATCH_EDIT_SHARED_READONLY_FIELDS, table.idFieldName.lower()] relationships = [ rel.name for rel in table.relationships - if rel.relatedModelName.lower() in READONLY_TABLES + if rel.relatedModelName.lower() in BATCH_EDIT_READONLY_TABLES ] if table.name.lower() == "determination": relationships = ["preferredtaxon"] elif is_tree_table(table): relationships = ["definitionitem"] - return fields, [*SHARED_READONLY_RELATIONSHIPS, *relationships] + return fields, [*BATCH_EDIT_SHARED_READONLY_RELATIONSHIPS, *relationships] FLOAT_FIELDS = ["java.lang.Float", "java.lang.Double", "java.math.BigDecimal"] @@ -179,7 +183,7 @@ def _query_field(field_spec: QueryFieldSpec, sort_type: int): display=True, format_name=None, sort_type=sort_type, - strict=False + strict=False, ) def _index( @@ -244,6 +248,12 @@ def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: if len(join_path) < 2: return False return isinstance(join_path[-2], TreeRankQuery) + +def get_tree_rank_record(key) -> TreeRankRecord: + from specifyweb.workbench.upload.treerecord import RANK_KEY_DELIMITER + + tree_name, rank_name, tree_def_id = tuple(key.split(RANK_KEY_DELIMITER)) + return TreeRankRecord(RANK_KEY_DELIMITER.join([tree_name, rank_name]), int(tree_def_id)) # These constants are purely for memory optimization, no code depends and/or cares if this is constant. @@ -261,6 +271,7 @@ class RowPlanMap(NamedTuple): to_one: Dict[str, "RowPlanMap"] = {} to_many: Dict[str, "RowPlanMap"] = {} is_naive: bool = True + tree_rank: Optional[TreeRankQuery] = None @staticmethod def _merge( @@ -282,12 +293,16 @@ def merge(self: "RowPlanMap", other: "RowPlanMap") -> "RowPlanMap": # That is, we'll currently incorrectly disallow making new ones. Fine for now. to_one = reduce(RowPlanMap._merge, other.to_one.items(), self.to_one) to_many = reduce(RowPlanMap._merge, other.to_many.items(), self.to_many) + assert not ( + (self.tree_rank is None) ^ (other.tree_rank is None) + ), "Trying to merge inconsistent rowplanmaps" return RowPlanMap( batch_edit_pack, new_columns, to_one, to_many, is_naive=is_self_naive, + tree_rank=self.tree_rank, ) @staticmethod @@ -306,6 +321,10 @@ def _index( # to make things simpler, returns the QueryFields along with indexed plan, which are expected to be used together def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: + intermediary_to_tree = any( + rowmap.tree_rank is not None for _, rowmap in self.to_one.items() + ) + next_index = len(self.columns) + start_index # For optimization, and sanity, we remove the field from columns, as they are now completely redundant (we always know what they are using the id) _columns = [ @@ -324,7 +343,7 @@ def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: next_index, _to_one, to_one_fields = reduce( RowPlanMap._index, # makes the order deterministic, would be funny otherwise - Func.sort_by_key(self.to_one), + Func.obj_to_list(self.to_one) if intermediary_to_tree else Func.sort_by_key(self.to_one), init(next_index), ) next_index, _to_many, to_many_fields = reduce( @@ -406,35 +425,36 @@ def _recur_row_plan( original_field, ) - boiler = RowPlanMap(columns=[], batch_edit_pack=batch_edit_pack) - - def _augment_is_naive(rel_type: Union[Literal["to_one"], Literal["to_many"]]): + remaining_map = remaining_map._replace(tree_rank=node if isinstance(node, TreeRankQuery) else None) - rest_plan = {rel_name: remaining_map} - if rel_type == "to_one": - # Propagate is_naive up - return boiler._replace( - is_naive=remaining_map.is_naive, to_one=rest_plan - ) + boiler = RowPlanMap( + columns=[], + batch_edit_pack=batch_edit_pack, + ) - # bc the user eperience guys want to be able to make new dets/preps one hop away - # but, we can't allow it for ordernumber when filtering. pretty annoying. - # and definitely not naive for any tree, well, technically it is possible, but for user's sake. - is_naive = not is_tree_table(next_table) and ( - ( - len(running_path) == 0 - and (remaining_map.batch_edit_pack.order.field is None) - ) - or remaining_map.is_naive - ) + rest_plan = {rel_name: remaining_map} + if rel_type == "to_one": + # Propagate is_naive up return boiler._replace( - to_many={ - # to force-naiveness - rel_name: remaining_map._replace(is_naive=is_naive) - } + is_naive=remaining_map.is_naive, to_one=rest_plan ) - return _augment_is_naive(rel_type) + # bc the user eperience guys want to be able to make new dets/preps one hop away + # but, we can't allow it for ordernumber when filtering. pretty annoying. + # and definitely not naive for any tree, well, technically it is possible, but for user's sake. + is_naive = not is_tree_table(next_table) and ( + ( + len(running_path) == 0 + and (remaining_map.batch_edit_pack.order.field is None) + ) + or remaining_map.is_naive + ) + return boiler._replace( + to_many={ + # to force-naiveness + rel_name: remaining_map._replace(is_naive=is_naive) + } + ) # generates multiple row plan maps, and merges them into one # this doesn't index the row plan, bc that is complicated. @@ -494,7 +514,9 @@ def nullify(self, parent_is_phantom=False) -> "RowPlanCanonical": # since is_naive is set, is_phantom = parent_is_phantom or not self.is_naive columns = [ - pack._replace(value=NULL_RECORD_DESCRIPTION if is_phantom else None) + pack._replace( + value=BATCH_EDIT_NULL_RECORD_DESCRIPTION if is_phantom else None + ) for pack in self.columns ] to_ones = { @@ -536,6 +558,15 @@ def to_many_planner(self) -> "RowPlanMap": to_many=to_many, ) + def rewrite( + self, table: Table, all_tree_info: TREE_INFORMATION, running_path=[] + ) -> "RowPlanMap": + from .batch_edit_query_rewrites import _batch_edit_rewrite # ugh, fix this + + # NOTE: This is written in a very generic way, and makes future rewrites also not too hard. + # However, tree rank rewrites was probably the hardest that needed to be done. + return _batch_edit_rewrite(self, table, all_tree_info, running_path) + # the main data-structure which stores the data # RowPlanMap is just a map, this stores actual data (to many is a dict of list, rather than just a dict) @@ -820,7 +851,7 @@ def _flatten(_: str, _self: "RowPlanCanonical"): def to_upload_plan( self, base_table: Table, - localization_dump: Dict[str, str], + localization_dump: Dict[str, Dict[str, str]], query_fields: List[QueryField], fields_added: Dict[str, int], get_column_id: Callable[[str], int], @@ -841,10 +872,15 @@ def _lookup_in_fields(_id: Optional[int], readonly_fields: List[str]): field = query_fields[ _id - 1 ] # Need to go off by 1, bc we added 1 to account for id fields + table_name, field_name = _get_table_and_field(field) + field_labels = localization_dump.get(table_name, {}) + # It could happen that the field we saw doesn't exist. + # Plus, the default options get chosen in the cases of + if field_name not in field_labels or field.fieldspec.contains_tree_rank(): + localized_label = naive_field_format(field.fieldspec) + else: + localized_label = field_labels[field_name] string_id = field.fieldspec.to_stringid() - localized_label = localization_dump.get( - string_id, naive_field_format(field.fieldspec) - ) fields_added[localized_label] = fields_added.get(localized_label, 0) + 1 _count = fields_added[localized_label] if _count > 1: @@ -856,7 +892,7 @@ def _lookup_in_fields(_id: Optional[int], readonly_fields: List[str]): or intermediary_to_tree or (fieldspec.is_temporal() and fieldspec.date_part != "Full Date") or fieldspec.get_field().name.lower() in readonly_fields - or fieldspec.table.name.lower() in READONLY_TABLES + or fieldspec.table.name.lower() in BATCH_EDIT_READONLY_TABLES ) id_in_original_fields = get_column_id(string_id) return ( @@ -930,7 +966,7 @@ def _relationship_is_editable(name, value): upload_plan: Uploadable = TreeRecord( name=base_table.django_name, ranks={ - key: upload_table.wbcols # type: ignore + get_tree_rank_record(key): upload_table.wbcols # type: ignore for (key, upload_table) in to_one_upload_tables.items() }, ) @@ -955,13 +991,16 @@ def _relationship_is_editable(name, value): # Using this as a last resort to show fields, for unit tests def naive_field_format(fieldspec: QueryFieldSpec): field = fieldspec.get_field() + tree_rank = fieldspec.get_first_tree_rank() + prefix = f"{tree_rank[1].treedef_name} - {tree_rank[1].name} - " if tree_rank else "" if field is None: - return f"{fieldspec.table.name} (formatted)" + return f"{prefix}{fieldspec.table.name} (formatted)" if field.is_relationship: - return f"{fieldspec.table.name} ({'formatted' if field.type.endswith('to-one') else 'aggregatd'})" - return f"{fieldspec.table.name} {field.name}" + return f"{prefix}{fieldspec.table.name} ({'formatted' if field.type.endswith('to-one') else 'aggregatd'})" + return f"{prefix}{fieldspec.table.name} {field.name}" +# @transaction.atomic <--- we DONT do this because the query logic could take up possibly multiple minutes def run_batch_edit(collection, user, spquery, agent): props = BatchEditProps( collection=collection, @@ -972,7 +1011,8 @@ def run_batch_edit(collection, user, spquery, agent): recordsetid=spquery.get("recordsetid", None), fields=fields_from_json(spquery["fields"]), session_maker=models.session_context, - omit_relationships=True + omit_relationships=True, + treedefsfilter=spquery.get("treedefsfilter", None) ) (headers, rows, packs, json_upload_plan, visual_order) = run_batch_edit_query(props) mapped_raws = [ @@ -992,7 +1032,6 @@ def run_batch_edit(collection, user, spquery, agent): ) -# @transaction.atomic <--- we DONT do this because the query logic could take up possibly multiple minutes class BatchEditProps(TypedDict): collection: Any user: Any @@ -1003,7 +1042,12 @@ class BatchEditProps(TypedDict): session_maker: Any fields: List[QueryField] omit_relationships: Optional[bool] + treedefsfilter: Any +def _get_table_and_field(field: QueryField): + table_name = field.fieldspec.table.name + field_name = None if field.fieldspec.get_field() is None else field.fieldspec.get_field().name + return (table_name, field_name) def run_batch_edit_query(props: BatchEditProps): @@ -1017,21 +1061,28 @@ def run_batch_edit_query(props: BatchEditProps): visible_fields = [field for field in fields if field.display] + treedefsfilter = props["treedefsfilter"] + assert captions is None or ( len(visible_fields) == len(captions) ), "Got misaligned captions!" - localization_dump: Dict[str, str] = ( - { - # we cannot use numbers since they can very off - field.fieldspec.to_stringid(): caption - for field, caption in zip(visible_fields, captions) - } - if captions is not None - else {} - ) + localization_dump = {} + if captions: + for (field, caption) in zip(visible_fields, captions): + table_name, field_name = _get_table_and_field(field) + field_labels = localization_dump.get(table_name, {}) + field_labels[field_name] = caption + localization_dump[table_name] = field_labels - row_plan = RowPlanMap.get_row_plan(visible_fields) + naive_row_plan = RowPlanMap.get_row_plan(visible_fields) + all_tree_info = get_all_tree_information(props["collection"], props["user"].id) + base_table = datamodel.get_table_by_id_strict(tableid, strict=True) + running_path = [base_table.name] + + if treedefsfilter is not None: + all_tree_info = filter_tree_info(treedefsfilter, all_tree_info) + row_plan = naive_row_plan.rewrite(base_table, all_tree_info, running_path) indexed, query_fields = row_plan.index_plan() # we don't really care about these fields, since we'have already done the numbering (and it won't break with @@ -1089,12 +1140,15 @@ def run_batch_edit_query(props: BatchEditProps): ), "Made irregular rows somewhere!" def _get_orig_column(string_id: str): - return next( + try: + return next( filter( lambda field: field[1].fieldspec.to_stringid() == string_id, enumerate(visible_fields), - ) - )[0] + ))[0] + except StopIteration: + # Put the other ones at the very last. + return len(visible_fields) # Consider optimizing when relationships are not-editable? May not benefit actually # This permission just gets enforced here @@ -1107,7 +1161,7 @@ def _get_orig_column(string_id: str): # The keys are lookups into original query field (not modified by us). Used to get ids in the original one. key_and_headers, upload_plan = extend_row.to_upload_plan( - datamodel.get_table_by_id_strict(tableid, strict=True), + base_table, localization_dump, query_fields, {}, @@ -1119,7 +1173,7 @@ def _get_orig_column(string_id: str): # We would have arbitarily sorted the columns, so our columns will not be correct. # Rather than sifting the data, we just add a default visual order. - visual_order = Func.first(sorted(headers_enumerated, key=lambda tup: tup[1][0])) + visual_order = Func.first(headers_enumerated) headers = Func.second(key_and_headers) @@ -1170,3 +1224,13 @@ def make_dataset( ds.save() return (ds_id, ds_name) + + +def filter_tree_info(filters: Dict[str, List[int]], all_tree_info: Dict[str, List[TREE_INFORMATION]]): + for tablename in filters: + treetable_key = tablename.title() + if treetable_key in all_tree_info: + tree_filter = set(filters[tablename]) + all_tree_info[treetable_key] = list(filter(lambda tree_info : tree_info['definition']['id'] in tree_filter, all_tree_info[treetable_key])) + + return all_tree_info \ No newline at end of file diff --git a/specifyweb/stored_queries/batch_edit_query_rewrites.py b/specifyweb/stored_queries/batch_edit_query_rewrites.py new file mode 100644 index 00000000000..3338fbc1a07 --- /dev/null +++ b/specifyweb/stored_queries/batch_edit_query_rewrites.py @@ -0,0 +1,208 @@ +from functools import reduce +from typing import Any, Dict, List, Set, Tuple +from specifyweb.specify.models import datamodel +from specifyweb.specify.func import Func +from specifyweb.specify.load_datamodel import Table +from specifyweb.specify.tree_views import TREE_INFORMATION +from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec, TreeRankQuery +from .batch_edit import BatchEditFieldPack, BatchEditPack, RowPlanMap + +BATCH_EDIT_REQUIRED_TREE_FIELDS: Set[str] = {"name"} + + +def _track_observed_ranks( + table_name, + running_path, + tree_def, + all_current_ranks: Dict[str, Dict[Any, Any]], + accum: Tuple[List[int], List[Tuple[str, RowPlanMap]]], + _current: Tuple[str, RowPlanMap], +): + # 1. if tree rank itself is None (non tree field), nothing to do. + # 2. if the tree rank that's in the query is not in the current ranks, ignore them. + # 3. if the tree rank already has a specialized tree. There is no current way in which this can naturally happen but this does avoid + # a future bug when multiple treedef queries are supported. + relname, current = _current + if ( + current.tree_rank is None + or (current.tree_rank.relatedModelName.lower() != table_name.lower()) + or current.tree_rank.treedef_id is not None + or (current.tree_rank.name not in all_current_ranks) + ): + return accum + + current_rank = all_current_ranks[current.tree_rank.name] + # Here, we also modify the columns to adjust the missing field stuff. + current_fields = Func.filter_list( + [ + None if column.field is None else column.field.fieldspec.get_field() + for column in current.columns + ] + ) + + # if the current_field is not found, insert them into the query with fields. + naive_field_spec = QueryFieldSpec.from_path(running_path) + adjusted_field_spec = lambda field_name: naive_field_spec._replace( + join_path=( + *naive_field_spec.join_path, + current.tree_rank, + naive_field_spec.table.get_field_strict(field_name), + ) + ) + # Now, we need to run the adjuster over all the fields that are required but did not appear + required_missing = BATCH_EDIT_REQUIRED_TREE_FIELDS - set( + field.name for field in current_fields + ) + extra_columns = [ + BatchEditFieldPack( + field=BatchEditPack._query_field(adjusted_field_spec(field_name), 0) + ) + for field_name in required_missing + ] + + new_columns = [*current.columns, *extra_columns] + new_tree_rank_query = TreeRankQuery.create( + current.tree_rank.name, current.tree_rank.relatedModelName, tree_def["id"],tree_def["name"] + ) + + new_columns = [] + for column in [*current.columns, *extra_columns]: + column_field = column.field + new_field_spec = column_field.fieldspec._replace( + join_path=tuple( + [ + new_tree_rank_query if isinstance(node, TreeRankQuery) else node + for node in column_field.fieldspec.join_path + ] + ) + ) + new_columns.append( + column._replace(field=column_field._replace(fieldspec=new_field_spec)) + ) + + return [*accum[0], current_rank["rankid"]], [ + *accum[1], + # Making a copy here is important. + (relname, current._replace(columns=new_columns, tree_rank=new_tree_rank_query)), + ] + + +def _rewrite_multiple_trees( + running_path, + current: Dict[str, RowPlanMap], + all_tree_info: Dict[str, List[TREE_INFORMATION]], +) -> Dict[str, RowPlanMap]: + # We now rewrite the query for multiple trees. We need to do this because we don't support querying a specific treedef. + # Multiple different iterations were went into this: + # 1. Trying it on frontend + # 2. Trying it on backend + # 2.a: Rewriting directly on fields + # This place is currently more simpler than other places tried. + + new_rels: List[Tuple[str, RowPlanMap]] = [ + (key, value) for (key, value) in current.items() if value.tree_rank is None + ] + + # TODO: Check if the first loop is needed at all? Just do alltree_info[table.name] and go from there? + for table_name, multiple_tree_info in all_tree_info.items(): + for single_tree_info in multiple_tree_info: + augmented_tree_info = { + rank["name"]: rank for rank in single_tree_info["ranks"] + } + ranks_found, rels_created = reduce( + lambda p, c: _track_observed_ranks( + table_name, running_path, single_tree_info['definition'], augmented_tree_info, p, c + ), + current.items(), + ([], []), + ) + # This means that no rank was selected for this tree, so we completely skip this (no point in adding multiples) + if len(ranks_found) == 0: + continue + # We now add the new ranks that were initially missing. + min_rank_id = ranks_found[0] if len(ranks_found) == 1 else min(*ranks_found) + ranks_to_add = [ + rank["name"] + for rank in single_tree_info["ranks"] + if rank["rankid"] > min_rank_id + and rank["rankid"] not in ranks_found + ] + fieldspec = QueryFieldSpec.from_path(running_path) + # To make things "simpler", we just run the reducer again. + template_plans = {} + for rank in ranks_to_add: + tree_rank_query = TreeRankQuery.create(rank, table_name) + adjusted = fieldspec._replace( + join_path=(*fieldspec.join_path, tree_rank_query) + ) + template_plans = { + **template_plans, + rank: RowPlanMap( + batch_edit_pack=BatchEditPack.from_field_spec(adjusted), + tree_rank = tree_rank_query + ) + } + final_ranks_created, final_rels_created = reduce( + lambda p, c: _track_observed_ranks( + table_name, running_path, single_tree_info['definition'], augmented_tree_info, p, c + ), + template_plans.items(), + ([], []), + ) + assert len(final_ranks_created) == len(ranks_to_add) + + new_rels = [ + *new_rels, + # NOTE: The order between finals_rels_created and rels_created does not matter + *rels_created, + *final_rels_created + ] + + # Now, we'have done the iteration over all the possible treees and have made the corresponding tree query ranks in the columns + # just scoped to a specific query. The only thing remaining is adjusting the name of the relationship being used. + # Note that new_rels is a list on purpose. Now, it contains all the relationships corrected, but it contain duplicated first key. + # We now make them deduplicated, by using the unique name that treerankquery makes. + new_rels = [ + ( + rel if value.tree_rank is None else value.tree_rank.get_workbench_name(), + value, + ) + for rel, value in new_rels + ] + # Duplicates are not possible here. + assert len(set(Func.first(new_rels))) == len( + new_rels + ), f"Duplicates created: {new_rels}" + + # It is not this function's responsibility to perform rewrites on next plans. + return {key: value for (key, value) in new_rels} + +def _safe_table(key: str, table: Table): + field = table.get_field(key) + if field is None: + return table + return datamodel.get_table_strict(field.relatedModelName) + +def _batch_edit_rewrite( + self: RowPlanMap, table: Table, all_tree_info: TREE_INFORMATION, running_path=[] +) -> RowPlanMap: + + to_ones = { + key: value.rewrite( + _safe_table(key, table), + all_tree_info, + [*running_path, key], + ) + for (key, value) in self.to_one.items() + } + to_many = { + key: value.rewrite( + _safe_table(key, table), + all_tree_info, + [*running_path, key], + ) + for (key, value) in self.to_many.items() + } + to_ones = _rewrite_multiple_trees(running_path, to_ones, all_tree_info) + to_many = _rewrite_multiple_trees(running_path, to_many, all_tree_info) + return self._replace(to_one=to_ones, to_many=to_many) \ No newline at end of file diff --git a/specifyweb/stored_queries/query_construct.py b/specifyweb/stored_queries/query_construct.py index 8c4f5e07637..0203497c17f 100644 --- a/specifyweb/stored_queries/query_construct.py +++ b/specifyweb/stored_queries/query_construct.py @@ -28,13 +28,13 @@ def __new__(cls, *args, **kwargs): kwargs['internal_filters'] = [] return super(QueryConstruct, cls).__new__(cls, *args, **kwargs) - def handle_tree_field(self, node, table, tree_rank, next_join_path, current_field_spec: QueryFieldSpec): + def handle_tree_field(self, node, table, tree_rank: TreeRankQuery, next_join_path, current_field_spec: QueryFieldSpec): query = self if query.collection is None: raise AssertionError( # Not sure it makes sense to query across collections f"No Collection found in Query for {table}", {"table" : table, "localizationKey" : "noCollectionInQuery"}) - logger.info('handling treefield %s rank: %s field: %s', table, tree_rank, next_join_path) + logger.info('handling treefield %s rank: %s field: %s', table, tree_rank.name, next_join_path) treedefitem_column = table.name + 'TreeDefItemID' treedef_column = table.name + 'TreeDefID' @@ -65,47 +65,33 @@ def handle_tree_field(self, node, table, tree_rank, next_join_path, current_fiel # TODO: optimize out the ranks that appear? cache them treedefs_with_ranks: List[Tuple[int, int]] = [tup for tup in [ - (treedef_id, _safe_filter(item_model.objects.filter(treedef_id=treedef_id, name=tree_rank).values_list('id', flat=True))) + (treedef_id, _safe_filter(item_model.objects.filter(treedef_id=treedef_id, name=tree_rank.name).values_list('id', flat=True))) for treedef_id, _ in treedefs + # For constructing tree queries for batch edit + if (tree_rank.treedef_id is None or tree_rank.treedef_id == treedef_id) ] if tup[1] is not None] assert len(treedefs_with_ranks) >= 1, "Didn't find the tree rank across any tree" - column_name = next_join_path[0].name - - # NOTE: Code from #4929 - # def make_tree_field_spec(tree_node): - # return current_field_spec._replace( - # root_table=table, # rebasing the query - # root_sql_table=tree_node, # this is needed to preserve SQL aliased going to next part - # join_path=next_join_path, # slicing join path to begin from after the tree - # ) - - # cases = [] - # field = None # just to stop mypy from complaining. - # for ancestor in ancestors: - # field_spec = make_tree_field_spec(ancestor) - # query, orm_field, field, table = field_spec.add_spec_to_query(query) - # #field and table won't matter. rank acts as fork, and these two will be same across siblings - # cases.append((getattr(ancestor, treedefitem_column) == treedefitem_param, orm_field)) - - # column = sql.case(cases) - - # return query, column, field, table - - def _predicates_for_node(_node): - return [ - # TEST: consider taking the treedef_id comparison just to the first node, if it speeds things up (matching for higher is redundant..) - (sql.and_(getattr(_node, treedef_column)==treedef_id, getattr(_node, treedefitem_column)==treedefitem_id), getattr(_node, column_name)) - for (treedef_id, treedefitem_id) in treedefs_with_ranks - ] - - cases_per_ancestor = [ - _predicates_for_node(ancestor) - for ancestor in ancestors - ] - - column = sql.case([case for per_ancestor in cases_per_ancestor for case in per_ancestor]) + treedefitem_params = [treedefitem_id for (_, treedefitem_id) in treedefs_with_ranks] + + def make_tree_field_spec(tree_node): + return current_field_spec._replace( + root_table=table, # rebasing the query + root_sql_table=tree_node, # this is needed to preserve SQL aliased going to next part + join_path=next_join_path, # slicing join path to begin from after the tree + ) + + cases = [] + field = None # just to stop mypy from complaining. + for ancestor in ancestors: + field_spec = make_tree_field_spec(ancestor) + query, orm_field, field, table = field_spec.add_spec_to_query(query) + # Field and table won't matter. Rank acts as fork, and these two will be same across siblings + for treedefitem_param in treedefitem_params: + cases.append((getattr(ancestor, treedefitem_column) == treedefitem_param, orm_field)) + + column = sql.case(cases) defs_to_filter_on = [def_id for (def_id, _) in treedefs_with_ranks] # We don't want to include treedef if the rank is not present. @@ -113,8 +99,8 @@ def _predicates_for_node(_node): *query.internal_filters, or_(getattr(node, treedef_column).in_(defs_to_filter_on), getattr(node, treedef_column) == None)] query = query._replace(internal_filters=new_filters) - - return query, column, current_field_spec.get_field(), table + + return query, column, field, table def tables_in_path(self, table, join_path): path = deque(join_path) @@ -170,4 +156,4 @@ def proxy(self, *args, **kwargs): setattr(QueryConstruct, name, proxy) for name in 'filter join outerjoin add_columns reset_joinpoint group_by'.split(): - add_proxy_method(name) + add_proxy_method(name) \ No newline at end of file diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index b9309fd25e0..4a3b7f498d6 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -1,4 +1,3 @@ -from dataclasses import fields import logging import re from collections import namedtuple, deque @@ -31,19 +30,20 @@ DATE_PART_RE = re.compile(r"(.*)((NumericDay)|(NumericMonth)|(NumericYear))$") # Pull out author or groupnumber field from taxon query fields. -TAXON_FIELD_RE = re.compile(r'(.*) ((Author)|(groupNumber))$') +TAXON_FIELD_RE = re.compile(r"(.*) ((Author)|(groupNumber))$") # Pull out geographyCode field from geography query fields. -GEOGRAPHY_FIELD_RE = re.compile(r'(.*) ((geographyCode))$') +GEOGRAPHY_FIELD_RE = re.compile(r"(.*) ((geographyCode))$") # Look to see if we are dealing with a tree node ID. -TREE_ID_FIELD_RE = re.compile(r'(.*) (ID)$') +TREE_ID_FIELD_RE = re.compile(r"(.*) (ID)$") # Precalculated fields that are not in the database. Map from table name to field name. PRECALCULATED_FIELDS = { - 'CollectionObject': 'age', + "CollectionObject": "age", } + def extract_date_part(fieldname): match = DATE_PART_RE.match(fieldname) if match: @@ -82,11 +82,18 @@ def find_tree_and_field(table, fieldname: str): fieldname = fieldname.strip() if fieldname == "": return None, None - # NOTE: Assumes rank names have no spaces + tree_rank_and_field = fieldname.split(" ") mapping = make_tree_fieldnames(table) + + # BUG: Edge case when there's no field AND rank name has a space? if len(tree_rank_and_field) == 1: return tree_rank_and_field[0], mapping[""] + + # Handles case where rank name contains spaces + if len(tree_rank_and_field) > 2: + tree_rank_and_field = [" ".join(tree_rank_and_field[:-1]), tree_rank_and_field[-1]] + tree_rank, tree_field = tree_rank_and_field return tree_rank, mapping.get(tree_field, tree_field) @@ -107,10 +114,41 @@ def make_stringid(fs, table_list): field_name += "Numeric" + fs.date_part return table_list, fs.table.name.lower(), field_name.strip() + class TreeRankQuery(Relationship): # FUTURE: used to remember what the previous value was. Useless after 6 retires original_field: str - pass + # This is used to query a particular treedef. If this is none, all treedefs are searched, otherwise a specific treedef is searched. + treedef_id: Optional[int] + # Yeah this can be inferred from treedef_id but doing it this way avoids a database lookup because we already fetch it once. + treedef_name: Optional[str] + + def __hash__(self): + return hash((TreeRankQuery, self.relatedModelName, self.name)) + + def __eq__(self, value): + return ( + isinstance(value, TreeRankQuery) + and value.name == self.name + and value.relatedModelName == self.relatedModelName + ) + + @staticmethod + def create(name, table_name, treedef_id=None, treedef_name=None): + obj = TreeRankQuery( + name=name, + relatedModelName=table_name, + type="many-to-one", + column=datamodel.get_table_strict(table_name).idFieldName + ) + obj.treedef_id = treedef_id + obj.treedef_name = treedef_name + return obj + + def get_workbench_name(self): + from specifyweb.workbench.upload.treerecord import RANK_KEY_DELIMITER + # Treedef id included to make it easier to pass it to batch edit + return f"{self.treedef_name}{RANK_KEY_DELIMITER}{self.name}{RANK_KEY_DELIMITER}{self.treedef_id}" QueryNode = Union[Field, Relationship, TreeRankQuery] @@ -118,7 +156,10 @@ class TreeRankQuery(Relationship): class QueryFieldSpec( - namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part tree_rank tree_field") + namedtuple( + "QueryFieldSpec", + "root_table root_sql_table join_path table date_part tree_rank tree_field", + ) ): root_table: Table root_sql_table: SQLTable @@ -157,7 +198,7 @@ def from_path(cls, path_in, add_id=False): "Full Date" if (join_path and join_path[-1].is_temporal()) else None ), tree_rank=None, - tree_field=None + tree_field=None, ) @classmethod @@ -190,11 +231,9 @@ def from_stringid(cls, stringid, is_relation): if field is None: # try finding tree tree_rank_name, field = find_tree_and_field(node, extracted_fieldname) if tree_rank_name: - tree_rank = TreeRankQuery( - name=tree_rank_name, - relatedModelName=node.name, - type="many-to-one", - column=node.idField.column + tree_rank = TreeRankQuery.create( + tree_rank_name, + node.name ) # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent join_path.append(tree_rank) @@ -213,7 +252,7 @@ def from_stringid(cls, stringid, is_relation): table=node, date_part=date_part, tree_rank=tree_rank_name, - tree_field=field + tree_field=field, ) logger.debug( @@ -228,8 +267,17 @@ def from_stringid(cls, stringid, is_relation): def __init__(self, *args, **kwargs): self.validate() + def get_first_tree_rank(self): + for node in enumerate(list(self.join_path)): + if isinstance(node[1], TreeRankQuery): + return node + return None + + def contains_tree_rank(self): + return self.get_first_tree_rank() is not None + def validate(self): - valid_date_parts = ('Full Date', 'Day', 'Month', 'Year', None) + valid_date_parts = ("Full Date", "Day", "Month", "Year", None) assert self.is_temporal() or self.date_part is None if self.date_part not in valid_date_parts: raise AssertionError( @@ -294,21 +342,23 @@ def is_specify_username_end(self): def needs_formatted(self): return len(self.join_path) == 0 or self.is_relationship() - + def apply_filter( - self, - query, - orm_field, - field, - table, - value=None, - op_num=None, - negate=False, - strict=False, - collection=None, - user=None): - - no_filter = op_num is None or (self.tree_rank is None and self.get_field() is None) + self, + query, + orm_field, + field, + table, + value=None, + op_num=None, + negate=False, + strict=False, + collection=None, + user=None + ): + no_filter = op_num is None or ( + self.tree_rank is None and self.get_field() is None + ) if not no_filter: if isinstance(value, QueryFieldSpec): _, other_field, _ = value.add_to_query(query.reset_joinpoint()) @@ -323,7 +373,9 @@ def apply_filter( query_op = QueryOps(uiformatter) op = query_op.by_op_num(op_num) if query_op.is_precalculated(op_num): - f = op(orm_field, value, query, is_strict=strict) # Needed if using op_age_range_simple + f = op( + orm_field, value, query, is_strict=strict + ) # Needed if using op_age_range_simple # Handle modifying query from op_age_range # new_query = op(orm_field, value, query, is_strict=strict) # query = query._replace(query=new_query) @@ -362,7 +414,9 @@ def add_to_query( # print "is auditlog obj format field = " + str(self.is_auditlog_obj_format_field(formatauditobjs)) # print "############################################################################" query, orm_field, field, table = self.add_spec_to_query(query, formatter) - return self.apply_filter(query, orm_field, field, table, value, op_num, negate, strict=strict, collection=collection, user=user) + return self.apply_filter( + query, orm_field, field, table, value, op_num, negate, strict=strict, collection=collection, user=user + ) def add_spec_to_query( self, query, formatter=None, aggregator=None, cycle_detector=[] @@ -377,7 +431,7 @@ def add_spec_to_query( if self.is_relationship(): # will be formatting or aggregating related objects - if self.get_field().type in {'many-to-one', 'one-to-one'}: + if self.get_field().type in {"many-to-one", "one-to-one"}: query, orm_model, table, field = self.build_join(query, self.join_path) query, orm_field = query.objectformatter.objformat( query, orm_model, formatter, cycle_detector @@ -400,7 +454,7 @@ def add_spec_to_query( query, orm_field, field, table = query.handle_tree_field( orm_model, table, - field.name, + field, self.join_path[tree_rank_idx + 1 :], self, ) @@ -412,7 +466,9 @@ def add_spec_to_query( if table.name in PRECALCULATED_FIELDS: field_name = PRECALCULATED_FIELDS[table.name] # orm_field = getattr(orm_model, field_name) - orm_field = getattr(orm_model, orm_model._id) # Replace with recordId, future just remove column from results + orm_field = getattr( + orm_model, orm_model._id + ) # Replace with recordId, future just remove column from results else: raise diff --git a/specifyweb/stored_queries/tests/static/co_query_row_plan.py b/specifyweb/stored_queries/tests/static/co_query_row_plan.py index fd83e149aaa..752db734da3 100644 --- a/specifyweb/stored_queries/tests/static/co_query_row_plan.py +++ b/specifyweb/stored_queries/tests/static/co_query_row_plan.py @@ -121,7 +121,7 @@ to_many={}, is_naive=True, ), - "County": RowPlanMap( + "Province": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack( field=None, idx=42, value=None @@ -142,7 +142,7 @@ to_many={}, is_naive=True, ), - "Province": RowPlanMap( + "County": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack( field=None, idx=45, value=None @@ -193,7 +193,7 @@ ), columns=[], to_one={ - "Genus": RowPlanMap( + "Subspecies": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack(field=None, idx=53, value=None), order=BatchEditFieldPack( @@ -227,7 +227,7 @@ to_many={}, is_naive=True, ), - "Subspecies": RowPlanMap( + "Genus": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack(field=None, idx=59, value=None), order=BatchEditFieldPack( diff --git a/specifyweb/stored_queries/tests/test_batch_edit.py b/specifyweb/stored_queries/tests/test_batch_edit.py index 5aa34cde7ec..5fa4beceff3 100644 --- a/specifyweb/stored_queries/tests/test_batch_edit.py +++ b/specifyweb/stored_queries/tests/test_batch_edit.py @@ -38,7 +38,8 @@ def _builder(query_fields, base_table): captions=None, limit=None, recordsetid=None, - omit_relationships=False + omit_relationships=False, + treedefsfilter=None ) return _builder @@ -85,6 +86,7 @@ def _create(model, kwargs): ) def test_query_construction(self): + self.maxDiff = None query = json.load(open("specifyweb/stored_queries/tests/static/co_query.json")) query_fields = fields_from_json(query["fields"]) visible_fields = [field for field in query_fields if field.display] @@ -153,9 +155,9 @@ def test_basic_run(self): "CollectionObject catalogNumber", "CollectionObject integer1", "Agent (formatted)", + "CollectingEvent (formatted)", "Agent firstName", "Agent lastName", - "CollectingEvent (formatted)", "Locality localityName", ], ) @@ -305,6 +307,7 @@ def test_basic_run(self): @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_duplicates_flattened(self): + self.maxDiff = None base_table = "collectionobject" query_paths = [ ["catalognumber"], @@ -551,12 +554,6 @@ def test_duplicates_flattened(self): "CollectionObject catalogNumber", "CollectionObject integer1", "Agent (formatted)", - "Determination integer1", - "Determination integer1 #2", - "Determination integer1 #3", - "Determination remarks", - "Determination remarks #2", - "Determination remarks #3", "Agent firstName", "Agent lastName", "AgentSpecialty specialtyName", @@ -564,21 +561,27 @@ def test_duplicates_flattened(self): "AgentSpecialty specialtyName #3", "AgentSpecialty specialtyName #4", "Collector remarks", - "Collector remarks #2", - "Collector remarks #3", - "Collector remarks #4", - "Collector remarks #5", - "Collector remarks #6", - "Collector remarks #7", - "Collector remarks #8", "CollectingEvent stationFieldNumber", + "Collector remarks #2", "CollectingEvent stationFieldNumber #2", + "Collector remarks #3", "CollectingEvent stationFieldNumber #3", + "Collector remarks #4", "CollectingEvent stationFieldNumber #4", + "Collector remarks #5", "CollectingEvent stationFieldNumber #5", + "Collector remarks #6", "CollectingEvent stationFieldNumber #6", + "Collector remarks #7", "CollectingEvent stationFieldNumber #7", + "Collector remarks #8", "CollectingEvent stationFieldNumber #8", + "Determination integer1", + "Determination remarks", + "Determination integer1 #2", + "Determination remarks #2", + "Determination integer1 #3", + "Determination remarks #3", ], ) diff --git a/specifyweb/workbench/upload/tests/example_plan.py b/specifyweb/workbench/upload/tests/example_plan.py index cc548b96823..7d2987e9f5f 100644 --- a/specifyweb/workbench/upload/tests/example_plan.py +++ b/specifyweb/workbench/upload/tests/example_plan.py @@ -34,11 +34,31 @@ )}, 'taxon': { 'treeRecord': dict( ranks = { - 'Class': 'Class', - 'Superfamily': 'Superfamily', - 'Family': 'Family', - 'Genus': 'Genus', - 'Subgenus': 'Subgenus', + 'Class': dict( + treeNodeCols = { + 'name': 'Class', + }, + ), + 'Superfamily': dict( + treeNodeCols = { + 'name': 'Superfamily', + }, + ), + 'Family': dict( + treeNodeCols = { + 'name': 'Family', + }, + ), + 'Genus': dict( + treeNodeCols = { + 'name': 'Genus', + }, + ), + 'Subgenus': dict( + treeNodeCols = { + 'name': 'Subgenus', + }, + ), 'Species': dict( treeNodeCols = { 'name': 'Species', @@ -77,10 +97,26 @@ toOne = { 'geography': { 'treeRecord': dict( ranks = { - 'Continent': 'Continent/Ocean' , - 'Country': 'Country', - 'State': 'State/Prov/Pref', - 'County': 'Region', + 'Continent': dict( + treeNodeCols = { + 'name': 'Continent/Ocean', + }, + ), + 'Country': dict( + treeNodeCols = { + 'name': 'Country', + }, + ), + 'State': dict( + treeNodeCols = { + 'name': 'State/Prov/Pref', + }, + ), + 'County': dict( + treeNodeCols = { + 'name': 'Region', + }, + ), } )}, }, diff --git a/specifyweb/workbench/upload/tests/testschema.py b/specifyweb/workbench/upload/tests/testschema.py index 2fcdda21df4..43a7e28558f 100644 --- a/specifyweb/workbench/upload/tests/testschema.py +++ b/specifyweb/workbench/upload/tests/testschema.py @@ -13,11 +13,18 @@ def set_plan_treeId(): # Set the treeId in example_plan.json dynamically, so that the unit test doesn't depend on the static treeId to always be the same. - from specifyweb.specify.models import Taxontreedefitem - tree_id = Taxontreedefitem.objects.filter(name='Species').first().treedef_id + def set_tree_id_for_ranks(ranks, model, name): + tree_id = model.objects.filter(name=name).first().treedef_id + for rank in ranks.keys(): + ranks[rank]['treeId'] = tree_id + + from specifyweb.specify.models import Taxontreedefitem, Geographytreedefitem + example_plan_ranks = example_plan.json['uploadable']['uploadTable']['toMany']['determinations'][0]['toOne']['taxon']['treeRecord']['ranks'] - for rank in ['Species', 'Subspecies']: - example_plan_ranks[rank]['treeId'] = tree_id + set_tree_id_for_ranks(example_plan_ranks, Taxontreedefitem, 'Species') + + geography_ranks = example_plan.json['uploadable']['uploadTable']['toOne']['collectingevent']['uploadTable']['toOne']['locality']['uploadTable']['toOne']['geography']['treeRecord']['ranks'] + set_tree_id_for_ranks(geography_ranks, Geographytreedefitem, 'Continent') class SchemaTests(UploadTestsBase): maxDiff = None diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index e545c208117..e1cca803cfc 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -51,6 +51,9 @@ logger = logging.getLogger(__name__) +# Rank keys in the upload plan have the format: ~> +RANK_KEY_DELIMITER = "~>" + class TreeRankCell(NamedTuple): treedef_id: int treedefitem_name: str @@ -78,7 +81,7 @@ def extract_treedef_name(rank_name: str) -> Tuple[str, Optional[str]]: """ Extract treedef_name from rank_name if it exists in the format 'treedef_name~>rank_name'. """ - parts = rank_name.split('~>', 1) + parts = rank_name.split(RANK_KEY_DELIMITER, 1) if len(parts) == 2: treedef_name = parts[0] rank_name = parts[1] @@ -126,6 +129,7 @@ def validate_rank(self, tableName: str) -> bool: return treedefitem_model.objects.filter(name=self.rank_name, treedef_id=self.treedef_id).exists() + class TreeRecord(NamedTuple): name: str ranks: Dict[Union[str, TreeRankRecord], Dict[str, ColumnOptions]] @@ -147,7 +151,7 @@ def to_json(self) -> Dict: rank_key = rank.rank_name if isinstance(rank, TreeRankRecord) else rank treeNodeCols = {k: v.to_json() if hasattr(v, "to_json") else v for k, v in cols.items()} - if len(cols) == 1: + if len(cols) == 1 and not isinstance(rank, TreeRankRecord): result["ranks"][rank_key] = treeNodeCols["name"] else: rank_data = {"treeNodeCols": treeNodeCols} @@ -186,6 +190,8 @@ def apply_batch_edit_pack( return self # batch-edit considers ranks as self-relationships, and are trivially stored in to-one rank_from_pack = batch_edit_pack.get("to_one", {}) + if rank_from_pack is None: + rank_from_pack = {} return self._replace( batch_edit_pack={ rank: pack["self"] for (rank, pack) in rank_from_pack.items() @@ -900,11 +906,13 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: previous_parent_id = None for tdi in self.treedefitems[::-1]: - if tdi.name not in self.batch_edit_pack: + ref_key = f"{tdi.treedef.name}{RANK_KEY_DELIMITER}{tdi.name}{RANK_KEY_DELIMITER}{tdi.treedef.id}" + tree_rank_record = TreeRankRecord(tdi.name, tdi.treedef.id) + if ref_key not in self.batch_edit_pack: continue - columns = [pr.column for pr in self.parsedFields[tdi.name]] + columns = [pr.column for pr in self.parsedFields[tree_rank_record]] info = ReportInfo(tableName=self.name, columns=columns, treeInfo=None) - pack = self.batch_edit_pack[tdi.name] + pack = self.batch_edit_pack[ref_key] try: reference = safe_fetch( model, {"id": pack["id"]}, pack.get("version", None)