diff --git a/specifyweb/businessrules/rules/cojo_rules.py b/specifyweb/businessrules/rules/cojo_rules.py index 3156c6d3ce7..8c01bfdacf6 100644 --- a/specifyweb/businessrules/rules/cojo_rules.py +++ b/specifyweb/businessrules/rules/cojo_rules.py @@ -1,28 +1,27 @@ -import os -import sys from enum import Enum +from django.db.models import Max + from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Collectionobjectgroupjoin + class COGType(Enum): DISCRETE = "Discrete" CONSOLIDATED = "Consolidated" DRILL_CORE = "Drill Core" -def is_running_tests(): - return any(module in sys.modules for module in ('pytest', 'unittest')) @orm_signal_handler('pre_save', 'Collectionobjectgroupjoin') def cojo_pre_save(cojo): # Ensure the both the childcog and childco fields are not null. - # if cojo.childcog == None and cojo.childco == None: - # raise BusinessRuleException('Both childcog and childco cannot be null.') + if cojo.childcog is None and cojo.childco is None: + raise BusinessRuleException('Both childcog and childco cannot be null.') # Ensure the childcog and childco fields are not both set. - # if cojo.childcog != None and cojo.childco != None: - # raise BusinessRuleException('Both childcog and childco cannot be set.') + if cojo.childcog is not None and cojo.childco is not None: + raise BusinessRuleException('Both childcog and childco cannot be set.') # For records with the same parentcog field, there can be only one isPrimare field set to True. # So when a record is saved with isPrimary set to True, we need to set all other records with the same parentcog @@ -41,24 +40,33 @@ def cojo_pre_save(cojo): cojo.childcog is not None and cojo.childcog.cojo is not None and cojo.childcog.cojo.id is not cojo.id - and not is_running_tests() ): - raise BusinessRuleException('ChildCog is already in use as a child in another COG.') + raise BusinessRuleException( + 'ChildCog is already in use as a child in another COG.') if ( cojo.childco is not None and cojo.childco.cojo is not None and cojo.childco.cojo.id is not cojo.id - and not is_running_tests() ): - raise BusinessRuleException('ChildCo is already in use as a child in another COG.') - + raise BusinessRuleException( + 'ChildCo is already in use as a child in another COG.') + + if cojo.precedence is None: + others = Collectionobjectgroupjoin.objects.filter( + parentcog=cojo.parentcog + ) + top = others.aggregate(Max('precedence'))['precedence__max'] + cojo.precedence = 0 if top is None else top + 1 + + @orm_signal_handler('post_save', 'Collectionobjectgroupjoin') def cojo_post_save(cojo): """ For Consolidated COGs, mark the first CO child as primary if none have been set by the user """ - co_children = Collectionobjectgroupjoin.objects.filter(parentcog=cojo.parentcog, childco__isnull=False) + co_children = Collectionobjectgroupjoin.objects.filter( + parentcog=cojo.parentcog, childco__isnull=False) if len(co_children) > 0 and not co_children.filter(isprimary=True).exists() and cojo.parentcog.cogtype.type == COGType.CONSOLIDATED.value: first_child = co_children.first() first_child.isprimary = True diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts index 971f4f33524..06d45420603 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts @@ -237,6 +237,13 @@ const fieldOverwrites: typeof globalFieldOverrides = { CollectionObject: { collectionObjectType: { visibility: 'optional' }, }, + CollectionObjectGroupType: { + type: { visibility: 'optional' }, + }, + CollectionObjectGroupJoin: { + precedence: { visibility: 'optional' }, + isSubstrate: { visibility: 'optional' }, + }, LoanPreparation: { isResolved: { visibility: 'optional' }, }, diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/PickListEditor.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/PickListEditor.tsx index 52b0400bc9f..05e26277ce6 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/PickListEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/PickListEditor.tsx @@ -10,7 +10,6 @@ import type { Collection } from '../DataModel/specifyTable'; import { getTable } from '../DataModel/tables'; import type { PickList } from '../DataModel/types'; import { IntegratedRecordSelector } from '../FormSliders/IntegratedRecordSelector'; -import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; export function PickListEditor({ resource, @@ -56,8 +55,7 @@ export function PickListEditor({ relationship={relationship} sortField={undefined} onAdd={ - relationshipIsToMany(relationship) && - relationship.type !== 'zero-to-one' + relationship.type.includes('-to-many') ? undefined : ([resource]): void => void resource.set(relationship.name, resource as never) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx index 5d18eb99be4..e013ec223a0 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx @@ -8,7 +8,6 @@ import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { Relationship } from '../DataModel/specifyField'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { useSearchDialog } from '../SearchDialog'; -import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { Slider } from './Slider'; export type RecordSelectorProps = { @@ -84,9 +83,7 @@ export function useRecordSelector({ ); const isToOne = - field === undefined - ? false - : !relationshipIsToMany(field) || field.type === 'zero-to-one'; + field === undefined ? false : !field.type.includes('-to-many'); const handleResourcesSelected = React.useMemo( () => diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx index aa2c572b570..949228d9d95 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx @@ -14,7 +14,6 @@ import type { SpecifyResource } from '../DataModel/legacyTypes'; import { resourceOn } from '../DataModel/resource'; import type { Relationship } from '../DataModel/specifyField'; import type { Collection } from '../DataModel/specifyTable'; -import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import type { RecordSelectorProps, RecordSelectorState, @@ -58,8 +57,7 @@ export function RecordSelectorFromCollection({ const isDependent = collection instanceof DependentCollection; const isLazy = collection instanceof LazyCollection; - const isToOne = - !relationshipIsToMany(relationship) || relationship.type === 'zero-to-one'; + const isToOne = !relationship.type.includes('-to-many'); // Listen for changes to collection React.useEffect( diff --git a/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx index a31b661de35..090378ed19e 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx @@ -139,10 +139,11 @@ export function SubView({ return ( - {!RECURSIVE_RENDERING_EXCEPTIONS.has(parentResource.specifyTable) && + {(!RECURSIVE_RENDERING_EXCEPTIONS.has(parentResource.specifyTable) && parentContext - .map(({ relationship }) => relationship) - .includes(relationship) || collection === false ? undefined : ( + .map(({ relationship }) => relationship) + .includes(relationship)) || + collection === false ? undefined : ( <> {isButton && ( ( collection: systemInfo.collection, collectionGUID: systemInfo.collection_guid, isaNumber: systemInfo.isa_number, - disciplineType: systemInfo.discipline_type + disciplineType: systemInfo.discipline_type, }, /* * I don't know if the receiving server handles GET parameters in a diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts b/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts index f4fb195f439..3b8e99bf066 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts +++ b/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts @@ -159,7 +159,9 @@ async function fetchFromField( fields: { [fieldName]: ['string', 'number', 'boolean', 'null'] }, distinct: true, domainFilter: true, - filterChronostrat: tableName === tables.GeologicTimePeriod.name.toLowerCase() && fieldName === "name", // Prop for age filter in QueryBuilder + filterChronostrat: + tableName === tables.GeologicTimePeriod.name.toLowerCase() && + fieldName === 'name', // Prop for age filter in QueryBuilder }).then((rows) => rows .map((row) => row[fieldName] ?? '') diff --git a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx index b34927c8c7c..1e776d86977 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx @@ -524,41 +524,45 @@ export function QueryComboBox({ .map(serializeResource) .map(({ fieldName, startValue }) => fieldName === 'rankId' - ? { - field: 'rankId', - isRelationship: false, - isNot: false, - operation: 'less', - value: startValue, - } - : fieldName === 'nodeNumber' - ? { - field: 'nodeNumber', - isRelationship: false, - operation: 'between', - isNot: true, - value: startValue, - } - : fieldName === 'collectionRelTypeId' ? { - field: 'id', + field: 'rankId', isRelationship: false, - operation: 'in', isNot: false, + operation: 'less', value: startValue, } - : fieldName === 'taxonTreeDefId' + : fieldName === 'nodeNumber' ? { - field: 'definition', - isRelationship: true, - operation: 'in', - isNot: false, - value: startValue + field: 'nodeNumber', + isRelationship: false, + operation: 'between', + isNot: true, + value: startValue, } - : f.error(`extended filter not created`, { - fieldName, - startValue, - })) + : fieldName === 'collectionRelTypeId' + ? { + field: 'id', + isRelationship: false, + operation: 'in', + isNot: false, + value: startValue, + } + : fieldName === 'taxonTreeDefId' + ? { + field: 'definition', + isRelationship: true, + operation: 'in', + isNot: false, + value: startValue, + } + : f.error( + `extended filter not created`, + { + fieldName, + startValue, + } + ) + ) ), }) : undefined diff --git a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx index 9b5bd1d3213..ea01ebff46b 100644 --- a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx @@ -139,7 +139,13 @@ const filterResults = ( function testFilter( resource: SpecifyResource, - { operation, field, value, isNot, isRelationship }: QueryComboBoxFilter + { + operation, + field, + value, + isNot, + isRelationship, + }: QueryComboBoxFilter ): boolean { const values = value.split(',').map(f.trim); const result = @@ -151,7 +157,9 @@ function testFilter( values.some((value) => { const fieldValue = resource.get(field); // eslint-disable-next-line eqeqeq - return isRelationship ? value == strictIdFromUrl(fieldValue!).toString() : value == fieldValue + return isRelationship + ? value == strictIdFromUrl(fieldValue!).toString() + : value == fieldValue; }) : operation === 'less' ? values.every((value) => (resource.get(field) ?? 0) < value) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/autoMapper.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/autoMapper.ts index 1192e618a8f..f78001c9edc 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/autoMapper.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/autoMapper.ts @@ -30,6 +30,7 @@ import { getNameFromTreeRankName, getNumberFromToManyIndex, mappingPathToString, + relationshipIsRemoteToOne, relationshipIsToMany, valueIsToManyIndex, valueIsTreeRank, @@ -809,7 +810,10 @@ export class AutoMapper { .forEach((relationship) => { const localPath = [...mappingPath, relationship.name]; - if (relationshipIsToMany(relationship)) + if ( + relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship) + ) localPath.push(formatToManyIndex(1)); const newDepthLevel = localPath.length; diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/helpers.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/helpers.ts index 7b7dd213d92..a540ff24c2b 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/helpers.ts @@ -28,6 +28,7 @@ import { formatToManyIndex, formatTreeRank, mappingPathToString, + relationshipIsRemoteToOne, relationshipIsToMany, valueIsToManyIndex, valueIsTreeRank, @@ -266,10 +267,13 @@ export function mutateMappingPath({ const table = getTable(parentTableName ?? ''); const currentField = table?.getField(mappingPath[index] ?? ''); const isCurrentToMany = - currentField?.isRelationship === true && relationshipIsToMany(currentField); + currentField?.isRelationship === true && + (relationshipIsToMany(currentField) || + relationshipIsRemoteToOne(currentField)); const newField = table?.getField(newValue); const isNewToMany = - newField?.isRelationship === true && relationshipIsToMany(newField); + newField?.isRelationship === true && + (relationshipIsToMany(newField) || relationshipIsRemoteToOne(newField)); const isNewTree = newField?.isRelationship === true && isTreeTable(newField.relatedTable.name); diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts index dc65961182d..6265f184cdc 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts @@ -22,6 +22,21 @@ export const relationshipIsToMany = ( relationship?.type.includes('-to-many') === true || relationship?.type === 'zero-to-one'; +/** + * Returns whether the relatation is one-to-one from the remote side + * (the foreign key exists on the other table of the relationship) + * + * In the WorkBench, remote one-to-one relationships are parsed as to-many + * in the upload plan + * + * See https://github.com/specify/specify7/pull/6073#discussion_r1915397675 + */ +export const relationshipIsRemoteToOne = ( + relationship: Relationship | undefined +): boolean => + relationship?.type === 'one-to-one' && + relationship.databaseColumn === undefined; + export type FieldType = Exclude; /** Returns whether a value is a -to-many index (e.x #1, #2, etc...) */ diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts index 9acc365b8c9..36a4ca97c62 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts @@ -18,6 +18,7 @@ import type { MappingPath } from './Mapper'; import { formatTreeRank, getNumberFromToManyIndex, + relationshipIsRemoteToOne, relationshipIsToMany, valueIsToManyIndex, valueIsTreeRank, @@ -103,8 +104,10 @@ export function findRequiredMissingFields( // Disable circular relationships (isCircularRelationship(parentRelationship, relationship) || // Skip -to-many inside -to-many - (relationshipIsToMany(parentRelationship) && - relationshipIsToMany(relationship))) + ((relationshipIsToMany(parentRelationship) || + relationshipIsRemoteToOne(parentRelationship)) && + (relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship)))) ) return []; diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index b7a1b46606c..1dd5a3291a1 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -36,6 +36,7 @@ import { getNameFromTreeDefinitionName, getNameFromTreeRankName, parsePartialField, + relationshipIsRemoteToOne, relationshipIsToMany, valueIsPartialField, valueIsToManyIndex, @@ -121,7 +122,8 @@ function navigator({ if (next === undefined) return; const childrenAreToManyElements = - relationshipIsToMany(parentRelationship) && + (relationshipIsToMany(parentRelationship) || + relationshipIsRemoteToOne(parentRelationship)) && !valueIsToManyIndex(parentPartName) && !valueIsTreeMeta(parentPartName); @@ -328,7 +330,9 @@ export function getMappingLineData({ internalState.defaultValue, ]); - const isToOne = parentRelationship?.type === 'zero-to-one'; + const isToOne = + parentRelationship?.type === 'one-to-one' || + parentRelationship?.type === 'zero-to-one'; const toManyLimit = isToOne ? 1 : Number.POSITIVE_INFINITY; const additional = maxMappedElementNumber < toManyLimit @@ -588,8 +592,10 @@ export function getMappingLineData({ parentRelationship === undefined || (!isCircularRelationship(parentRelationship, field) && !( - relationshipIsToMany(field) && - relationshipIsToMany(parentRelationship) + (relationshipIsToMany(field) || + relationshipIsRemoteToOne(field)) && + (relationshipIsToMany(parentRelationship) || + relationshipIsRemoteToOne(parentRelationship)) )); isIncluded &&= @@ -611,7 +617,10 @@ export function getMappingLineData({ * Hide -to-many relationships to a tree table as they are * not supported by the WorkBench */ - !relationshipIsToMany(field) || + !( + relationshipIsToMany(field) || + relationshipIsRemoteToOne(field) + ) || !isTreeTable(field.relatedTable.name); } diff --git a/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx index 2ac9bff2452..be9d7b3c7a0 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx @@ -6,7 +6,6 @@ import type { SpecifyResource } from '../components/DataModel/legacyTypes'; import type { Relationship } from '../components/DataModel/specifyField'; import type { Collection } from '../components/DataModel/specifyTable'; import type { SubViewSortField } from '../components/FormParse/cells'; -import { relationshipIsToMany } from '../components/WbPlanView/mappingHelpers'; import type { GetOrSet } from '../utils/types'; import { overwriteReadOnly } from '../utils/types'; import { sortFunction } from '../utils/utils'; @@ -34,8 +33,7 @@ export function useCollection({ >( React.useCallback( async () => - relationshipIsToMany(relationship) && - relationship.type !== 'zero-to-one' + relationship.type.includes('-to-many') ? fetchToManyCollection({ parentResource, relationship, @@ -61,16 +59,14 @@ export function useCollection({ versionRef.current += 1; const localVersionRef = versionRef.current; - const fetchCollection = - relationshipIsToMany(relationship) && - relationship.type !== 'zero-to-one' - ? fetchToManyCollection({ - parentResource, - relationship, - sortBy, - filters, - }) - : fetchToOneCollection({ parentResource, relationship, filters }); + const fetchCollection = relationship.type.includes('-to-many') + ? fetchToManyCollection({ + parentResource, + relationship, + sortBy, + filters, + }) + : fetchToOneCollection({ parentResource, relationship, filters }); return fetchCollection.then((collection) => { if ( diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index a1f94d3e2d0..03201d6459a 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -1,6 +1,6 @@ import json import logging -from typing import List, Optional +from typing import List, Optional, Dict, Literal, get_args as get_typing_args from uuid import uuid4 from django import http @@ -12,8 +12,8 @@ from jsonschema.exceptions import ValidationError # type: ignore from specifyweb.middleware.general import require_GET, require_http_methods -from specifyweb.specify.api import create_obj, get_object_or_404, obj_to_data, \ - toJson, uri_for_model +from specifyweb.celery_tasks import CELERY_TASK_STATE +from specifyweb.specify.api import get_object_or_404 from specifyweb.specify.views import login_maybe_required, openapi from specifyweb.specify.models import Recordset, Specifyuser from specifyweb.notifications.models import Message @@ -35,6 +35,9 @@ class DataSetPT(PermissionTarget): transfer = PermissionTargetAction() create_recordset = PermissionTargetAction() +WorkbenchUpdateStatus = Literal["PROGRESS", "PENDING", "FAILURE"] + + def regularize_rows(ncols: int, rows: List[List]) -> List[List[str]]: n = ncols + 1 # extra row info such as disambiguation in hidden col at end @@ -93,11 +96,7 @@ def regularize(row: List) -> Optional[List]: }, "taskstatus": { "type": "string", - "enum": [ - "PROGRESS", - "PENDING", - "FAILURE", - ] + "enum": list(get_typing_args(WorkbenchUpdateStatus)) }, "uploaderstatus": { "type": "object", @@ -729,16 +728,26 @@ def status(request, ds_id: int) -> http.HttpResponse: if ds.uploaderstatus is None: return http.JsonResponse(None, safe=False) + + task_status_map: Dict[str, WorkbenchUpdateStatus] = { + CELERY_TASK_STATE.RECEIVED: "PENDING", + CELERY_TASK_STATE.STARTED: "PENDING", + CELERY_TASK_STATE.SUCCESS: "PENDING", + CELERY_TASK_STATE.RETRY: "FAILURE", + CELERY_TASK_STATE.REVOKED: "FAILURE", + } task = { 'uploading': tasks.upload, 'validating': tasks.upload, 'unuploading': tasks.unupload, }[ds.uploaderstatus['operation']] + result = task.AsyncResult(ds.uploaderstatus['taskid']) + status = { 'uploaderstatus': ds.uploaderstatus, - 'taskstatus': result.state, + 'taskstatus': task_status_map.get(result.state, result.state), 'taskinfo': result.info if isinstance(result.info, dict) else repr(result.info) } return http.JsonResponse(status)