From ee01fea458d3df95b66411e7209065fd364fa5ae Mon Sep 17 00:00:00 2001 From: alec_dev Date: Sun, 1 Mar 2026 00:58:20 -0600 Subject: [PATCH 01/26] intial batch identify features --- specifyweb/backend/batch_identify/__init__.py | 0 specifyweb/backend/batch_identify/urls.py | 8 + specifyweb/backend/batch_identify/views.py | 364 ++++++++ .../lib/components/BatchIdentify/index.tsx | 804 ++++++++++++++++++ .../components/Header/userToolDefinitions.ts | 11 + .../lib/components/Router/OverlayRoutes.tsx | 9 + .../js_src/lib/localization/batchIdentify.ts | 54 ++ specifyweb/specify/urls.py | 1 + 8 files changed, 1251 insertions(+) create mode 100644 specifyweb/backend/batch_identify/__init__.py create mode 100644 specifyweb/backend/batch_identify/urls.py create mode 100644 specifyweb/backend/batch_identify/views.py create mode 100644 specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx create mode 100644 specifyweb/frontend/js_src/lib/localization/batchIdentify.ts diff --git a/specifyweb/backend/batch_identify/__init__.py b/specifyweb/backend/batch_identify/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/specifyweb/backend/batch_identify/urls.py b/specifyweb/backend/batch_identify/urls.py new file mode 100644 index 00000000000..c6b101625fb --- /dev/null +++ b/specifyweb/backend/batch_identify/urls.py @@ -0,0 +1,8 @@ +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path(r'^batch_identify/resolve/$', views.batch_identify_resolve), + re_path(r'^batch_identify/$', views.batch_identify), +] diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py new file mode 100644 index 00000000000..6a5a9de3f24 --- /dev/null +++ b/specifyweb/backend/batch_identify/views.py @@ -0,0 +1,364 @@ +import json +import re +from collections.abc import Iterable +from typing import Any, Literal + +from django import http +from django.db import transaction +from django.db.models import IntegerField, Prefetch, Q +from django.db.models.functions import Cast +from django.views.decorators.http import require_POST + +from specifyweb.backend.permissions.permissions import check_table_permissions +from specifyweb.specify.api.calculated_fields import calculate_extra_fields +from specifyweb.specify.api.crud import post_resource +from specifyweb.specify.models import Collectionobject, Determination +from specifyweb.specify.views import login_maybe_required + +_RESOURCE_URI_ID_RE = re.compile(r'/(\d+)/?$') +_MAX_RESOLVE_COLLECTION_OBJECTS = 1000 +_METADATA_KEYS = { + 'id', + 'resource_uri', + 'recordset_info', + '_tablename', + 'version', +} + +CatalogToken = int | Literal['-'] + +def _tokenize_catalog_entry(entry: str) -> list[CatalogToken]: + tokens: list[CatalogToken] = [] + current_number: list[str] = [] + + for character in entry: + if character.isdigit(): + current_number.append(character) + continue + + if len(current_number) > 0: + tokens.append(int(''.join(current_number))) + current_number = [] + + if character == '-': + tokens.append('-') + + if len(current_number) > 0: + tokens.append(int(''.join(current_number))) + + return tokens + +def _parse_catalog_number_ranges(entries: Iterable[str]) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + + for raw_entry in entries: + entry = raw_entry.strip() + if entry == '': + continue + + tokens = _tokenize_catalog_entry(entry) + if not any(isinstance(token, int) for token in tokens): + continue + + index = 0 + while index < len(tokens): + token = tokens[index] + if token == '-': + index += 1 + continue + + start = token + end = start + if ( + index + 2 < len(tokens) + and tokens[index + 1] == '-' + and isinstance(tokens[index + 2], int) + ): + end = tokens[index + 2] + index += 3 + else: + index += 1 + + if start > end: + start, end = end, start + ranges.append((start, end)) + + if len(ranges) == 0: + raise ValueError('Provide at least one catalog number.') + + return ranges + +def _build_catalog_query(ranges: Iterable[tuple[int, int]]) -> Q: + query = Q() + for start, end in ranges: + if start == end: + query |= Q(catalog_number_int=start) + else: + query |= Q(catalog_number_int__gte=start, catalog_number_int__lte=end) + return query + +def _find_unmatched_catalog_numbers( + ranges: Iterable[tuple[int, int]], matched_catalog_numbers: Iterable[int] +) -> list[str]: + requested_numbers: set[int] = set() + for start, end in ranges: + requested_numbers.update(range(start, end + 1)) + + matched_numbers = { + catalog_number + for catalog_number in matched_catalog_numbers + if isinstance(catalog_number, int) + } + + return sorted( + (str(number) for number in requested_numbers - matched_numbers), key=int + ) + +def _sanitize_determination_payload(payload: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in payload.items() + if key.lower() not in _METADATA_KEYS and key.lower() != 'collectionobject' + } + +def _parse_catalog_numbers(request_data: dict[str, Any]) -> list[str]: + catalog_numbers = request_data.get('catalogNumbers') + if not isinstance(catalog_numbers, list): + raise ValueError("'catalogNumbers' must be a list of strings.") + if not all(isinstance(entry, str) for entry in catalog_numbers): + raise ValueError("'catalogNumbers' must be a list of strings.") + return catalog_numbers + +def _parse_validate_only(request_data: dict[str, Any]) -> bool: + validate_only = request_data.get('validateOnly', False) + if not isinstance(validate_only, bool): + raise ValueError("'validateOnly' must be a boolean.") + return validate_only + +def _fetch_collection_objects_by_catalog_ranges( + collection_id: int, + catalog_ranges: Iterable[tuple[int, int]], + include_current_determinations: bool = True, + max_results: int | None = None, +) -> list[Collectionobject]: + queryset = ( + Collectionobject.objects.filter(collectionmemberid=collection_id) + .exclude(catalognumber__isnull=True) + .exclude(catalognumber='') + .annotate(catalog_number_int=Cast('catalognumber', IntegerField())) + .filter(_build_catalog_query(catalog_ranges)) + .order_by('catalog_number_int', 'id') + ) + if include_current_determinations: + queryset = queryset.prefetch_related( + Prefetch( + 'determinations', + queryset=Determination.objects.only('id', 'iscurrent'), + to_attr='prefetched_determinations', + ) + ) + if isinstance(max_results, int): + queryset = queryset[:max_results] + return list(queryset) + +def _fetch_matched_catalog_numbers( + collection_id: int, catalog_ranges: Iterable[tuple[int, int]] +) -> set[int]: + return { + catalog_number + for catalog_number in Collectionobject.objects.filter( + collectionmemberid=collection_id + ) + .exclude(catalognumber__isnull=True) + .exclude(catalognumber='') + .annotate(catalog_number_int=Cast('catalognumber', IntegerField())) + .filter(_build_catalog_query(catalog_ranges)) + .values_list('catalog_number_int', flat=True) + if isinstance(catalog_number, int) + } + +def _extract_current_determination_ids( + collection_objects: Iterable[Collectionobject], +) -> list[int]: + current_determination_ids: list[int] = [] + for collection_object in collection_objects: + determinations = getattr(collection_object, 'prefetched_determinations', []) + extra = calculate_extra_fields( + collection_object, + { + 'determinations': [ + { + 'resource_uri': ( + f"/api/specify/determination/{determination.id}/" + ), + 'iscurrent': determination.iscurrent, + } + for determination in determinations + ] + }, + ) + resource_uri = extra.get('currentdetermination') + if not isinstance(resource_uri, str): + continue + current_determination_match = _RESOURCE_URI_ID_RE.search(resource_uri) + if current_determination_match is None: + continue + current_determination_ids.append(int(current_determination_match.group(1))) + return current_determination_ids + +def _parse_collection_object_ids(request_data: dict[str, Any]) -> list[int]: + collection_object_ids = request_data.get('collectionObjectIds') + if not isinstance(collection_object_ids, list): + raise ValueError("'collectionObjectIds' must be a list of numbers.") + if not all( + isinstance(collection_object_id, int) + for collection_object_id in collection_object_ids + ): + raise ValueError("'collectionObjectIds' must be a list of numbers.") + + deduplicated_ids: list[int] = [] + seen: set[int] = set() + for collection_object_id in collection_object_ids: + if collection_object_id <= 0 or collection_object_id in seen: + continue + seen.add(collection_object_id) + deduplicated_ids.append(collection_object_id) + + if len(deduplicated_ids) == 0: + raise ValueError('Provide at least one collection object ID.') + + return deduplicated_ids + +@login_maybe_required +@require_POST +def batch_identify_resolve(request: http.HttpRequest): + check_table_permissions( + request.specify_collection, request.specify_user, Collectionobject, 'read' + ) + + try: + request_data = json.loads(request.body) + except json.JSONDecodeError: + return http.JsonResponse({'error': 'Invalid JSON body.'}, status=400) + + try: + catalog_numbers = _parse_catalog_numbers(request_data) + catalog_ranges = _parse_catalog_number_ranges(catalog_numbers) + validate_only = _parse_validate_only(request_data) + except ValueError as error: + return http.JsonResponse({'error': str(error)}, status=400) + + collection_objects = _fetch_collection_objects_by_catalog_ranges( + request.specify_collection.id, + catalog_ranges, + include_current_determinations=not validate_only, + max_results=_MAX_RESOLVE_COLLECTION_OBJECTS, + ) + matched_catalog_numbers = _fetch_matched_catalog_numbers( + request.specify_collection.id, catalog_ranges + ) + + collection_object_ids = [ + collection_object.id for collection_object in collection_objects + ] + current_determination_ids = ( + _extract_current_determination_ids(collection_objects) + if not validate_only + else [] + ) + unmatched_catalog_numbers = _find_unmatched_catalog_numbers( + catalog_ranges, matched_catalog_numbers + ) + return http.JsonResponse( + { + 'collectionObjectIds': collection_object_ids, + 'currentDeterminationIds': current_determination_ids, + 'unmatchedCatalogNumbers': unmatched_catalog_numbers, + } + ) + +@login_maybe_required +@require_POST +@transaction.atomic +def batch_identify(request: http.HttpRequest): + check_table_permissions( + request.specify_collection, request.specify_user, Collectionobject, 'read' + ) + check_table_permissions( + request.specify_collection, request.specify_user, Determination, 'create' + ) + + try: + request_data = json.loads(request.body) + except json.JSONDecodeError: + return http.JsonResponse({'error': 'Invalid JSON body.'}, status=400) + + try: + collection_object_ids = _parse_collection_object_ids(request_data) + except ValueError as error: + return http.JsonResponse({'error': str(error)}, status=400) + + determination_payload = request_data.get('determination') + if not isinstance(determination_payload, dict): + return http.JsonResponse( + {'error': "'determination' must be an object."}, status=400 + ) + + existing_collection_object_ids = set( + Collectionobject.objects.filter( + collectionmemberid=request.specify_collection.id, + id__in=collection_object_ids, + ).values_list('id', flat=True) + ) + missing_collection_object_ids = [ + collection_object_id + for collection_object_id in collection_object_ids + if collection_object_id not in existing_collection_object_ids + ] + if len(missing_collection_object_ids) > 0: + return http.JsonResponse( + { + 'error': ( + 'One or more collection object IDs do not exist or are not in' + ' the active collection.' + ) + }, + status=400, + ) + + cleaned_payload = _sanitize_determination_payload(determination_payload) + mark_as_current = cleaned_payload.get('isCurrent') is True + if mark_as_current: + check_table_permissions( + request.specify_collection, request.specify_user, Determination, 'update' + ) + + determination_ids: list[int] = [] + for collection_object_id in collection_object_ids: + if mark_as_current: + Determination.objects.filter( + collectionmemberid=request.specify_collection.id, + collectionobject_id=collection_object_id, + iscurrent=True, + ).update(iscurrent=False) + + determination = post_resource( + request.specify_collection, + request.specify_user_agent, + 'determination', + { + **cleaned_payload, + 'collectionobject': ( + f"/api/specify/collectionobject/{collection_object_id}/" + ), + }, + ) + determination_ids.append(determination.id) + + return http.JsonResponse( + { + 'createdCount': len(determination_ids), + 'collectionObjectIds': collection_object_ids, + 'determinationIds': determination_ids, + } + ) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx new file mode 100644 index 00000000000..e0fa4644711 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -0,0 +1,804 @@ +import React from 'react'; + +import { useValidation } from '../../hooks/useValidation'; +import { batchIdentifyText } from '../../localization/batchIdentify'; +import { commonText } from '../../localization/common'; +import { ajax } from '../../utils/ajax'; +import { f } from '../../utils/functools'; +import { localized } from '../../utils/types'; +import type { RA } from '../../utils/types'; +import { H3 } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { DataEntry } from '../Atoms/DataEntry'; +import { icons } from '../Atoms/Icons'; +import { Link } from '../Atoms/Link'; +import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; +import { fetchCollection } from '../DataModel/collection'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import { createResource } from '../DataModel/resource'; +import { serializeResource } from '../DataModel/serializers'; +import { tables } from '../DataModel/tables'; +import type { RecordSet } from '../DataModel/types'; +import { ResourceView } from '../Forms/ResourceView'; +import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import { userInformation } from '../InitialContext/userInformation'; +import { AutoGrowTextArea } from '../Molecules/AutoGrowTextArea'; +import { Dialog, dialogClassNames } from '../Molecules/Dialog'; +import { hasToolPermission } from '../Permissions/helpers'; +import { ProtectedTable } from '../Permissions/PermissionDenied'; +import type { QueryField } from '../QueryBuilder/helpers'; +import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; +import { OverlayContext } from '../Router/Router'; +import { useSearchDialog } from '../SearchDialog'; +import { RecordSetsDialog } from '../Toolbar/RecordSets'; + +type BatchIdentifyResolveResponse = { + readonly collectionObjectIds: RA; + readonly currentDeterminationIds: RA; + readonly unmatchedCatalogNumbers: RA; +}; + +type BatchIdentifySaveResponse = { + readonly createdCount: number; + readonly collectionObjectIds: RA; + readonly determinationIds: RA; +}; + +type Step = 'catalogNumbers' | 'determination'; + +type CatalogToken = number | '-'; + +const liveValidationDebounceMs = 1000; +const collectionObjectViewPathRe = /\/specify\/view\/collectionobject\/(\d+)\/?$/i; + +const parseCatalogNumberEntries = (rawEntries: string): RA => + rawEntries + .split('\n') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + +const tokenizeCatalogEntry = (entry: string): RA => { + const tokens: CatalogToken[] = []; + let currentNumber = ''; + + for (const character of entry) { + if (character >= '0' && character <= '9') { + currentNumber += character; + continue; + } + + if (currentNumber.length > 0) { + tokens.push(Number(currentNumber)); + currentNumber = ''; + } + + if (character === '-') tokens.push('-'); + } + + if (currentNumber.length > 0) tokens.push(Number(currentNumber)); + return tokens; +}; + +const parseCatalogNumberRanges = ( + entries: RA +): RA => + entries.flatMap((entry) => { + const tokens = tokenizeCatalogEntry(entry); + const ranges: Array = []; + let index = 0; + while (index < tokens.length) { + const token = tokens[index]; + if (token === '-') { + index += 1; + continue; + } + + let start = token; + let end = start; + const rangeEndToken = tokens[index + 2]; + if ( + tokens[index + 1] === '-' && + typeof rangeEndToken === 'number' + ) { + end = rangeEndToken; + index += 3; + } else index += 1; + + if (start > end) [start, end] = [end, start]; + ranges.push([start, end]); + } + return ranges; + }); + +const queryFilterDefaults = { + isNot: false, + isStrict: false, +} as const; + +const anyFilter = { + ...queryFilterDefaults, + type: 'any', + startValue: '', +} as const; + +const buildPreviewFields = ( + collectionObjectIds: RA +): RA => [ + { + id: 0, + mappingPath: ['collectionObjectId'], + sortType: undefined, + isDisplay: false, + filters: [ + { + ...queryFilterDefaults, + type: 'in', + startValue: collectionObjectIds.join(', '), + }, + ], + }, + { + id: 1, + mappingPath: ['catalogNumber'], + sortType: 'ascending', + isDisplay: true, + filters: [anyFilter], + }, + { + id: 2, + mappingPath: ['determinations', '#1', 'determinedDate'], + sortType: undefined, + isDisplay: true, + filters: [anyFilter], + }, + { + id: 3, + mappingPath: ['determinations', '#1', 'typeStatusName'], + sortType: undefined, + isDisplay: true, + filters: [anyFilter], + }, + { + id: 4, + mappingPath: ['determinations', '#1', 'preferredTaxon', 'name'], + sortType: undefined, + isDisplay: true, + filters: [anyFilter], + }, + { + id: 5, + mappingPath: ['determinations', '#1', 'taxon', 'name'], + sortType: undefined, + isDisplay: true, + filters: [anyFilter], + }, + { + id: 6, + mappingPath: ['determinations', '#1', 'isCurrent'], + sortType: undefined, + isDisplay: false, + filters: [ + { + ...queryFilterDefaults, + type: 'true', + startValue: '', + }, + ], + }, +]; + +const createBatchIdentifyPreviewQuery = () => + new tables.SpQuery.Resource() + .set('name', batchIdentifyText.previewQueryName()) + .set('contextName', tables.CollectionObject.name) + .set('contextTableId', tables.CollectionObject.tableId) + .set('selectDistinct', false) + .set('smushed', false) + .set('countOnly', false) + .set('formatAuditRecIds', false) + .set('specifyUser', userInformation.resource_uri) + .set('isFavorite', true) + .set('ordinal', 32_767); + +const fetchRecordSetCollectionObjectIds = async ( + recordSetId: number +): Promise> => { + const limit = 2000; + let offset = 0; + let totalCount = 0; + const collectionObjectIds: number[] = []; + + do { + const { records, totalCount: fetchedTotalCount } = await fetchCollection( + 'RecordSetItem', + { + recordSet: recordSetId, + domainFilter: false, + limit, + offset, + orderBy: 'id', + } + ); + totalCount = fetchedTotalCount; + collectionObjectIds.push(...records.map(({ recordId }) => recordId)); + offset += records.length; + if (records.length === 0) break; + } while (offset < totalCount); + + return f.unique(collectionObjectIds); +}; + +const createBatchIdentifyRecordSet = async ( + collectionObjectIds: RA +): Promise | undefined> => { + if ( + collectionObjectIds.length === 0 || + !hasToolPermission('recordSets', 'create') + ) + return undefined; + + const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' '); + return createResource('RecordSet', { + name: `${batchIdentifyText.updatedRecordSet()} ${timestamp}`, + version: 1, + type: 0, + dbTableId: tables.CollectionObject.tableId, + // @ts-expect-error Inline RecordSetItem creation is supported by the API + recordSetItems: f.unique(collectionObjectIds).map((recordId) => ({ + recordId, + })), + }); +}; + +export function BatchIdentifyOverlay(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + return ( + + + + + + ); +} + +function BatchIdentifyDialog({ + onClose: handleClose, +}: { + readonly onClose: () => void; +}): JSX.Element { + const loading = React.useContext(LoadingContext); + const { validationRef, setValidation } = useValidation(); + + const [step, setStep] = React.useState('catalogNumbers'); + const [catalogNumbers, setCatalogNumbers] = React.useState(''); + const [isIdentifying, setIsIdentifying] = React.useState(false); + const [isResolving, setIsResolving] = React.useState(false); + const [isLiveValidating, setIsLiveValidating] = React.useState(false); + const [isRecordSetDialogOpen, setIsRecordSetDialogOpen] = React.useState(false); + const [isVerificationDialogOpen, setIsVerificationDialogOpen] = + React.useState(); + const [collectionObjectIds, setCollectionObjectIds] = React.useState>( + [] + ); + const [createdRecordSet, setCreatedRecordSet] = React.useState< + SerializedResource | undefined + >(undefined); + const [showSuccessDialog, setShowSuccessDialog] = React.useState(false); + const [resolvedCollectionObjectIds, setResolvedCollectionObjectIds] = + React.useState>([]); + const [validatedCatalogNumbersKey, setValidatedCatalogNumbersKey] = + React.useState(''); + const [unmatchedCatalogNumbers, setUnmatchedCatalogNumbers] = React.useState< + RA + >([]); + const [previewRunCount, setPreviewRunCount] = React.useState(0); + const [selectedPreviewRows, setSelectedPreviewRows] = React.useState< + ReadonlySet + >(new Set()); + const [determination] = React.useState( + () => new tables.Determination.Resource().set('isCurrent', true) + ); + const [previewQuery] = React.useState(createBatchIdentifyPreviewQuery); + const liveValidationRequestTokenRef = React.useRef(0); + const liveValidationTimeoutRef = React.useRef< + ReturnType | undefined + >(undefined); + const stepRef = React.useRef(step); + + const catalogNumberEntries = React.useMemo( + () => parseCatalogNumberEntries(catalogNumbers), + [catalogNumbers] + ); + const catalogNumberRanges = React.useMemo( + () => parseCatalogNumberRanges(catalogNumberEntries), + [catalogNumberEntries] + ); + const previewFields = React.useMemo( + () => buildPreviewFields(collectionObjectIds), + [collectionObjectIds] + ); + const selectedPreviewRowsCount = selectedPreviewRows.size; + const catalogNumbersKey = React.useMemo( + () => catalogNumberEntries.join('\n'), + [catalogNumberEntries] + ); + + const handleAddCollectionObjects = React.useCallback( + (resources: RA<{ readonly id: number }>): void => { + const mergedCollectionObjectIds = f.unique([ + ...collectionObjectIds, + ...resources.map(({ id }) => id), + ]); + if (mergedCollectionObjectIds.length === collectionObjectIds.length) return; + setCollectionObjectIds(mergedCollectionObjectIds); + setSelectedPreviewRows(new Set()); + setPreviewRunCount((count) => count + 1); + }, + [collectionObjectIds] + ); + + const { searchDialog, showSearchDialog } = useSearchDialog({ + extraFilters: undefined, + forceCollection: undefined, + multiple: true, + table: tables.CollectionObject, + onSelected: handleAddCollectionObjects, + }); + + const handleRemoveSelectedCollectionObjects = React.useCallback((): void => { + if (selectedPreviewRowsCount === 0) return; + setCollectionObjectIds( + collectionObjectIds.filter((id) => !selectedPreviewRows.has(id)) + ); + setSelectedPreviewRows(new Set()); + setPreviewRunCount((count) => count + 1); + }, [collectionObjectIds, selectedPreviewRows, selectedPreviewRowsCount]); + + const handlePreviewPopOutClick = React.useCallback( + (event: React.MouseEvent): void => { + const target = event.target; + if (!(target instanceof Element)) return; + const link = target.closest( + 'a.print\\:hidden[target="_blank"]' + ) as HTMLAnchorElement | null; + if (link === null) return; + const match = collectionObjectViewPathRe.exec(link.href); + if (match === null) return; + const recordId = Number(match[1]); + if (!Number.isInteger(recordId) || recordId <= 0) return; + event.preventDefault(); + setIsVerificationDialogOpen(recordId); + }, + [] + ); + + React.useEffect(() => { + stepRef.current = step; + }, [step]); + + const resolveCatalogNumbers = React.useCallback( + async ( + entries: RA, + options: { + readonly validateOnly?: boolean; + readonly errorMode?: 'dismissible' | 'silent'; + } = {} + ): Promise => + ajax('/api/specify/batch_identify/resolve/', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + catalogNumbers: entries, + validateOnly: options.validateOnly === true, + }, + errorMode: options.errorMode ?? 'dismissible', + }).then(({ data }) => data), + [] + ); + + const proceedWithCollectionObjects = React.useCallback( + (resolvedIds: RA): void => { + setCollectionObjectIds(resolvedIds); + setSelectedPreviewRows(new Set()); + if (resolvedIds.length === 0) { + setStep('catalogNumbers'); + return; + } + setPreviewRunCount((count) => count + 1); + setStep('determination'); + }, + [] + ); + + const runLiveValidation = React.useCallback( + (entries: RA, entriesKey: string): void => { + const requestToken = liveValidationRequestTokenRef.current + 1; + liveValidationRequestTokenRef.current = requestToken; + setIsLiveValidating(true); + void resolveCatalogNumbers(entries, { + validateOnly: true, + errorMode: 'silent', + }) + .then((data) => { + if ( + requestToken !== liveValidationRequestTokenRef.current || + stepRef.current !== 'catalogNumbers' + ) + return; + setValidatedCatalogNumbersKey(entriesKey); + setResolvedCollectionObjectIds(data.collectionObjectIds); + setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); + }) + .catch(() => undefined) + .finally(() => { + if (requestToken !== liveValidationRequestTokenRef.current) return; + setIsLiveValidating(false); + }); + }, + [resolveCatalogNumbers] + ); + + const scheduleLiveValidation = React.useCallback( + (immediate: boolean): void => { + if (step !== 'catalogNumbers' || catalogNumberRanges.length === 0) return; + + if (liveValidationTimeoutRef.current !== undefined) { + globalThis.clearTimeout(liveValidationTimeoutRef.current); + liveValidationTimeoutRef.current = undefined; + } + if (validatedCatalogNumbersKey === catalogNumbersKey) return; + + if (immediate) { + runLiveValidation(catalogNumberEntries, catalogNumbersKey); + return; + } + + liveValidationTimeoutRef.current = globalThis.setTimeout(() => { + liveValidationTimeoutRef.current = undefined; + runLiveValidation(catalogNumberEntries, catalogNumbersKey); + }, liveValidationDebounceMs); + }, + [ + step, + catalogNumberRanges, + validatedCatalogNumbersKey, + catalogNumbersKey, + runLiveValidation, + catalogNumberEntries, + ] + ); + + React.useEffect(() => { + if (step !== 'catalogNumbers') { + if (liveValidationTimeoutRef.current !== undefined) { + globalThis.clearTimeout(liveValidationTimeoutRef.current); + liveValidationTimeoutRef.current = undefined; + } + liveValidationRequestTokenRef.current += 1; + setIsLiveValidating(false); + return; + } + + if (catalogNumberRanges.length === 0) { + setUnmatchedCatalogNumbers([]); + setResolvedCollectionObjectIds([]); + setValidatedCatalogNumbersKey(catalogNumbersKey); + return; + } + scheduleLiveValidation(false); + }, [ + step, + catalogNumberRanges, + scheduleLiveValidation, + catalogNumbersKey, + ]); + + React.useEffect(() => { + if (step !== 'catalogNumbers') return; + if (catalogNumbers.trim().length === 0 || catalogNumberRanges.length > 0) + setValidation([]); + else setValidation(batchIdentifyText.noCatalogNumbersParsed()); + }, [step, catalogNumbers, catalogNumberRanges, setValidation]); + + React.useEffect( + () => (): void => { + if (liveValidationTimeoutRef.current !== undefined) + globalThis.clearTimeout(liveValidationTimeoutRef.current); + liveValidationRequestTokenRef.current += 1; + }, + [] + ); + + const handleRecordSetSelected = React.useCallback( + (recordSet: SerializedResource): void => { + setIsRecordSetDialogOpen(false); + loading( + fetchRecordSetCollectionObjectIds(recordSet.id).then( + (recordSetCollectionObjectIds) => { + setUnmatchedCatalogNumbers([]); + setResolvedCollectionObjectIds([]); + setValidatedCatalogNumbersKey(''); + proceedWithCollectionObjects(recordSetCollectionObjectIds); + } + ) + ); + }, + [loading, proceedWithCollectionObjects] + ); + + const handleNext = React.useCallback((): void => { + if (catalogNumberRanges.length === 0 || isResolving || isLiveValidating) return; + + if (validatedCatalogNumbersKey === catalogNumbersKey) { + if (unmatchedCatalogNumbers.length > 0) return; + proceedWithCollectionObjects(resolvedCollectionObjectIds); + return; + } + + setIsResolving(true); + loading( + resolveCatalogNumbers(catalogNumberEntries).then((data) => { + setValidatedCatalogNumbersKey(catalogNumbersKey); + setResolvedCollectionObjectIds(data.collectionObjectIds); + setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); + if (data.unmatchedCatalogNumbers.length > 0) { + setCollectionObjectIds([]); + setStep('catalogNumbers'); + return; + } + proceedWithCollectionObjects(data.collectionObjectIds); + }) + .finally(() => setIsResolving(false)) + ); + }, [ + catalogNumberRanges, + isResolving, + isLiveValidating, + validatedCatalogNumbersKey, + catalogNumbersKey, + unmatchedCatalogNumbers, + proceedWithCollectionObjects, + resolvedCollectionObjectIds, + loading, + resolveCatalogNumbers, + catalogNumberEntries, + ]); + + const handleIdentify = React.useCallback( + (): void => { + if (collectionObjectIds.length === 0 || isIdentifying) return; + setIsIdentifying(true); + loading( + ajax('/api/specify/batch_identify/', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + collectionObjectIds, + determination: serializeResource(determination), + }, + errorMode: 'dismissible', + }) + .then(async ({ data }) => { + const recordSet = await createBatchIdentifyRecordSet( + data.collectionObjectIds + ).catch(() => undefined); + setCreatedRecordSet(recordSet); + setShowSuccessDialog(true); + }) + .finally(() => setIsIdentifying(false)) + ); + }, + [collectionObjectIds, isIdentifying, loading, determination] + ); + + const previewExtraButtons = React.useMemo( + () => ( + <> + + + + ), + [ + isIdentifying, + showSearchDialog, + selectedPreviewRowsCount, + handleRemoveSelectedCollectionObjects, + ] + ); + + if (showSuccessDialog) + return ( + {commonText.close()}} + header={batchIdentifyText.batchIdentify()} + onClose={handleClose} + > +
+

{batchIdentifyText.successMessage()}

+ {typeof createdRecordSet === 'object' ? ( + + {localized(createdRecordSet.name)} + + ) : undefined} +
+
+ ); + + return ( + <> + + {commonText.cancel()} + setIsRecordSetDialogOpen(true)} + > + {batchIdentifyText.recordSet()} + + 0 + } + onClick={handleNext} + > + {commonText.next()} + + + ) : ( + <> + setStep('catalogNumbers')}> + {commonText.back()} + + + {batchIdentifyText.identify()} + + {commonText.close()} + + ) + } + className={{ + container: dialogClassNames.extraWideContainer, + }} + header={batchIdentifyText.batchIdentify()} + icon={icons.batchEdit} + onClose={handleClose} + > + {step === 'catalogNumbers' ? ( +
+

{batchIdentifyText.instructions()}

+

{batchIdentifyText.catalogNumbers()}

+

+ {commonText.countLine({ + resource: batchIdentifyText.catalogNumbers(), + count: catalogNumberRanges.length, + })} +

+ scheduleLiveValidation(true)} + onValueChange={(value): void => { + setCatalogNumbers(value); + setUnmatchedCatalogNumbers([]); + setResolvedCollectionObjectIds([]); + setValidatedCatalogNumbersKey(''); + }} + /> + {isLiveValidating &&

{batchIdentifyText.validatingCatalogNumbers()}

} + {unmatchedCatalogNumbers.length > 0 && ( +
+

{batchIdentifyText.catalogNumbersNotFound()}

+ {unmatchedCatalogNumbers.map((catalogNumber, index) => ( +

{catalogNumber}

+ ))} +
+ )} +
+ ) : ( +
+

+ {commonText.countLine({ + resource: batchIdentifyText.catalogNumbers(), + count: catalogNumberRanges.length, + })} +

+

+ {commonText.countLine({ + resource: batchIdentifyText.collectionObjects(), + count: collectionObjectIds.length, + })} +

+

{batchIdentifyText.determinationInstructions()}

+ {unmatchedCatalogNumbers.length > 0 && ( +
+

{batchIdentifyText.catalogNumbersNotFound()}

+ {unmatchedCatalogNumbers.map((catalogNumber, index) => ( +

{catalogNumber}

+ ))} +
+ )} +
+
+ +
+
+ +
+
+
+ )} +
+ {isRecordSetDialogOpen && ( + + setIsRecordSetDialogOpen(false)} + onSelect={handleRecordSetSelected} + /> + + )} + {searchDialog} + {typeof isVerificationDialogOpen === 'number' && ( + + setIsVerificationDialogOpen(undefined)} + onDelete={undefined} + onSaved={f.void} + onSlide={undefined} + /> + + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts index cfb1bbf64ab..23287cae345 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts @@ -1,3 +1,4 @@ +import { batchIdentifyText } from '../../localization/batchIdentify'; import { commonText } from '../../localization/common'; import { headerText } from '../../localization/header'; import { preferencesText } from '../../localization/preferences'; @@ -140,6 +141,16 @@ const rawUserTools = ensure>>>()({ icon: icons.globe, }, }, + [commonText.tools()]: { + batchIdentify: { + title: batchIdentifyText.batchIdentify(), + url: '/specify/overlay/batch-identify/', + icon: icons.batchEdit, + enabled: () => + hasTablePermission('CollectionObject', 'read') && + hasTablePermission('Determination', 'create'), + }, + }, [headerText.documentation()]: { aboutSpecify: { title: welcomeText.aboutSpecify(), diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 86550dc04d1..a5770ab6670 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { attachmentsText } from '../../localization/attachments'; import { batchEditText } from '../../localization/batchEdit'; +import { batchIdentifyText } from '../../localization/batchIdentify'; import { commonText } from '../../localization/common'; import { headerText } from '../../localization/header'; import { interactionsText } from '../../localization/interactions'; @@ -74,6 +75,14 @@ export const overlayRoutes: RA = [ ({ FormsDialogOverlay }) => FormsDialogOverlay ), }, + { + path: 'batch-identify', + title: batchIdentifyText.batchIdentify(), + element: () => + import('../BatchIdentify').then( + ({ BatchIdentifyOverlay }) => BatchIdentifyOverlay + ), + }, { path: 'trees', title: treeText.trees(), diff --git a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts new file mode 100644 index 00000000000..00ec123579a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts @@ -0,0 +1,54 @@ +/** + * Localization strings used in Batch Identify tool + * + * @module + */ + +import { createDictionary } from './utils'; + +export const batchIdentifyText = createDictionary({ + batchIdentify: { + 'en-us': 'Batch Identify', + }, + catalogNumbers: { + 'en-us': 'Catalog Numbers', + }, + recordSet: { + 'en-us': 'Record Set', + }, + collectionObjects: { + 'en-us': 'Collection Objects', + }, + instructions: { + 'en-us': + 'Enter catalog numbers using any non-numeric delimiters (commas, spaces, text prefixes, etc.). Use a dash to declare numeric ranges like 0001 - 0150.', + }, + determinationInstructions: { + 'en-us': + 'Create the determination that will be applied to all matched collection objects.', + }, + catalogNumbersNotFound: { + 'en-us': 'Catalog Numbers Not Found', + }, + identify: { + 'en-us': 'Identify', + }, + successMessage: { + 'en-us': 'All records were identified to the specified taxon.', + }, + updatedRecordSet: { + 'en-us': 'Batch Identify Updated Records', + }, + noCatalogNumbersParsed: { + 'en-us': 'Enter at least one numeric catalog number.', + }, + validatingCatalogNumbers: { + 'en-us': 'Validating catalog numbers...', + }, + placeholder: { + 'en-us': '0001\n0002\n0003 - 0150', + }, + previewQueryName: { + 'en-us': 'Batch Identify Preview', + }, +}); diff --git a/specifyweb/specify/urls.py b/specifyweb/specify/urls.py index 01d54968618..e08c99b9536 100644 --- a/specifyweb/specify/urls.py +++ b/specifyweb/specify/urls.py @@ -9,6 +9,7 @@ # the main business data API re_path(r'^specify_schema/openapi.json$', schema.openapi), re_path(r'^specify_schema/(?P\w+)/$', schema.view), + re_path(r'^specify/', include('specifyweb.backend.batch_identify.urls')), # batch identify re_path(r'^specify/(?P\w+)/(?P\d+)/$', views.resource), # permissions added re_path(r'^specify/(?P\w+)/$', views.collection), # permissions added From 6e8867607229bf9d28560a363e462869be4bb65f Mon Sep 17 00:00:00 2001 From: alec_dev Date: Sun, 1 Mar 2026 01:17:21 -0600 Subject: [PATCH 02/26] localization fixes --- .../lib/components/BatchIdentify/index.tsx | 17 ++++++++++++----- .../js_src/lib/localization/batchIdentify.ts | 9 --------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index e0fa4644711..62d20921412 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -14,6 +14,7 @@ import { icons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { fetchCollection } from '../DataModel/collection'; +import { getField } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { createResource } from '../DataModel/resource'; import { serializeResource } from '../DataModel/serializers'; @@ -268,6 +269,12 @@ function BatchIdentifyDialog({ }): JSX.Element { const loading = React.useContext(LoadingContext); const { validationRef, setValidation } = useValidation(); + const catalogNumberLabel = getField( + tables.CollectionObject, + 'catalogNumber' + ).label; + const collectionObjectLabel = tables.CollectionObject.label; + const recordSetLabel = tables.RecordSet.label; const [step, setStep] = React.useState('catalogNumbers'); const [catalogNumbers, setCatalogNumbers] = React.useState(''); @@ -638,7 +645,7 @@ function BatchIdentifyDialog({ disabled={isResolving || isLiveValidating} onClick={(): void => setIsRecordSetDialogOpen(true)} > - {batchIdentifyText.recordSet()} + {recordSetLabel}

{batchIdentifyText.instructions()}

-

{batchIdentifyText.catalogNumbers()}

+

{catalogNumberLabel}

{commonText.countLine({ - resource: batchIdentifyText.catalogNumbers(), + resource: catalogNumberLabel, count: catalogNumberRanges.length, })}

@@ -713,13 +720,13 @@ function BatchIdentifyDialog({

{commonText.countLine({ - resource: batchIdentifyText.catalogNumbers(), + resource: catalogNumberLabel, count: catalogNumberRanges.length, })}

{commonText.countLine({ - resource: batchIdentifyText.collectionObjects(), + resource: collectionObjectLabel, count: collectionObjectIds.length, })}

diff --git a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts index 00ec123579a..b78b54407fd 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts @@ -10,15 +10,6 @@ export const batchIdentifyText = createDictionary({ batchIdentify: { 'en-us': 'Batch Identify', }, - catalogNumbers: { - 'en-us': 'Catalog Numbers', - }, - recordSet: { - 'en-us': 'Record Set', - }, - collectionObjects: { - 'en-us': 'Collection Objects', - }, instructions: { 'en-us': 'Enter catalog numbers using any non-numeric delimiters (commas, spaces, text prefixes, etc.). Use a dash to declare numeric ranges like 0001 - 0150.', From 9ad165fabcd08c7bac70df7fc161819d85c6e653 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Sun, 1 Mar 2026 07:23:59 +0000 Subject: [PATCH 03/26] Lint code with ESLint and Prettier Triggered by 6e8867607229bf9d28560a363e462869be4bb65f on branch refs/heads/issue-7764 --- .../js_src/lib/components/BatchIdentify/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 62d20921412..23ec0097334 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -5,8 +5,8 @@ import { batchIdentifyText } from '../../localization/batchIdentify'; import { commonText } from '../../localization/common'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; -import { localized } from '../../utils/types'; import type { RA } from '../../utils/types'; +import { localized } from '../../utils/types'; import { H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; @@ -59,7 +59,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; + const tokens: readonly CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -85,7 +85,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: Array = []; + const ranges: readonly (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -207,7 +207,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: number[] = []; + const collectionObjectIds: readonly number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -367,7 +367,7 @@ function BatchIdentifyDialog({ if (!(target instanceof Element)) return; const link = target.closest( 'a.print\\:hidden[target="_blank"]' - ) as HTMLAnchorElement | null; + ) ; if (link === null) return; const match = collectionObjectViewPathRe.exec(link.href); if (match === null) return; From 54ac413cce09f5c463c2ee1639ea79a832240d3c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:31:14 -0600 Subject: [PATCH 04/26] feat: update icon and form layout --- .../lib/components/BatchIdentify/index.tsx | 22 +++++-------------- .../components/Header/userToolDefinitions.ts | 6 ++--- .../js_src/lib/localization/batchIdentify.ts | 4 ---- 3 files changed, 7 insertions(+), 25 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 23ec0097334..ddabcd4caa8 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -678,7 +678,7 @@ function BatchIdentifyDialog({ container: dialogClassNames.extraWideContainer, }} header={batchIdentifyText.batchIdentify()} - icon={icons.batchEdit} + icon={icons.clipboardCopy} onClose={handleClose} > {step === 'catalogNumbers' ? ( @@ -717,20 +717,7 @@ function BatchIdentifyDialog({ )}
) : ( -
-

- {commonText.countLine({ - resource: catalogNumberLabel, - count: catalogNumberRanges.length, - })} -

-

- {commonText.countLine({ - resource: collectionObjectLabel, - count: collectionObjectIds.length, - })} -

-

{batchIdentifyText.determinationInstructions()}

+
{unmatchedCatalogNumbers.length > 0 && (

{batchIdentifyText.catalogNumbersNotFound()}

@@ -739,9 +726,10 @@ function BatchIdentifyDialog({ ))}
)} -
-
+
+
>>>()({ icon: icons.rss, }, }, - [commonText.import()]: { + [commonText.tools()]: { localityUpdate: { title: headerText.localityUpdateTool(), enabled: () => userInformation.isadmin, url: '/specify/import/locality-dataset/', icon: icons.globe, }, - }, - [commonText.tools()]: { batchIdentify: { title: batchIdentifyText.batchIdentify(), url: '/specify/overlay/batch-identify/', - icon: icons.batchEdit, + icon: icons.clipboardCopy, enabled: () => hasTablePermission('CollectionObject', 'read') && hasTablePermission('Determination', 'create'), diff --git a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts index b78b54407fd..df25b0398ad 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts @@ -14,10 +14,6 @@ export const batchIdentifyText = createDictionary({ 'en-us': 'Enter catalog numbers using any non-numeric delimiters (commas, spaces, text prefixes, etc.). Use a dash to declare numeric ranges like 0001 - 0150.', }, - determinationInstructions: { - 'en-us': - 'Create the determination that will be applied to all matched collection objects.', - }, catalogNumbersNotFound: { 'en-us': 'Catalog Numbers Not Found', }, From dadbf253b363f981d0c98cc2b90406aec5703ad5 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:50:41 -0600 Subject: [PATCH 05/26] feat: increase density --- .../js_src/lib/components/BatchIdentify/index.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index ddabcd4caa8..db34f1a0e19 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -273,7 +273,6 @@ function BatchIdentifyDialog({ tables.CollectionObject, 'catalogNumber' ).label; - const collectionObjectLabel = tables.CollectionObject.label; const recordSetLabel = tables.RecordSet.label; const [step, setStep] = React.useState('catalogNumbers'); @@ -685,12 +684,6 @@ function BatchIdentifyDialog({

{batchIdentifyText.instructions()}

{catalogNumberLabel}

-

- {commonText.countLine({ - resource: catalogNumberLabel, - count: catalogNumberRanges.length, - })} -

)}
-
+
Date: Mon, 2 Mar 2026 00:38:29 -0600 Subject: [PATCH 06/26] feat: improve record set behavior & dialog sizing fixes some style things too, tests --- .../lib/components/BatchIdentify/index.tsx | 182 +++++++++++++----- 1 file changed, 133 insertions(+), 49 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index db34f1a0e19..5221e1822c0 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -14,7 +14,6 @@ import { icons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { fetchCollection } from '../DataModel/collection'; -import { getField } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { createResource } from '../DataModel/resource'; import { serializeResource } from '../DataModel/serializers'; @@ -29,6 +28,7 @@ import { hasToolPermission } from '../Permissions/helpers'; import { ProtectedTable } from '../Permissions/PermissionDenied'; import type { QueryField } from '../QueryBuilder/helpers'; import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; +import { queryText } from '../../localization/query'; import { OverlayContext } from '../Router/Router'; import { useSearchDialog } from '../SearchDialog'; import { RecordSetsDialog } from '../Toolbar/RecordSets'; @@ -59,7 +59,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: readonly CatalogToken[] = []; + const tokens: CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -85,7 +85,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: readonly (readonly [number, number])[] = []; + const ranges: [number, number][] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -207,7 +207,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: readonly number[] = []; + const collectionObjectIds: number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -269,11 +269,8 @@ function BatchIdentifyDialog({ }): JSX.Element { const loading = React.useContext(LoadingContext); const { validationRef, setValidation } = useValidation(); - const catalogNumberLabel = getField( - tables.CollectionObject, - 'catalogNumber' - ).label; const recordSetLabel = tables.RecordSet.label; + const canCreateRecordSet = hasToolPermission('recordSets', 'create'); const [step, setStep] = React.useState('catalogNumbers'); const [catalogNumbers, setCatalogNumbers] = React.useState(''); @@ -289,6 +286,11 @@ function BatchIdentifyDialog({ const [createdRecordSet, setCreatedRecordSet] = React.useState< SerializedResource | undefined >(undefined); + const [identifiedCollectionObjectIds, setIdentifiedCollectionObjectIds] = + React.useState>([]); + const [isCreatingRecordSet, setIsCreatingRecordSet] = React.useState(false); + const [isBrowseAfterIdentifyOpen, setIsBrowseAfterIdentifyOpen] = + React.useState(false); const [showSuccessDialog, setShowSuccessDialog] = React.useState(false); const [resolvedCollectionObjectIds, setResolvedCollectionObjectIds] = React.useState>([]); @@ -364,7 +366,7 @@ function BatchIdentifyDialog({ (event: React.MouseEvent): void => { const target = event.target; if (!(target instanceof Element)) return; - const link = target.closest( + const link = target.closest( 'a.print\\:hidden[target="_blank"]' ) ; if (link === null) return; @@ -584,11 +586,9 @@ function BatchIdentifyDialog({ }, errorMode: 'dismissible', }) - .then(async ({ data }) => { - const recordSet = await createBatchIdentifyRecordSet( - data.collectionObjectIds - ).catch(() => undefined); - setCreatedRecordSet(recordSet); + .then(({ data }) => { + setCreatedRecordSet(undefined); + setIdentifiedCollectionObjectIds(f.unique(data.collectionObjectIds)); setShowSuccessDialog(true); }) .finally(() => setIsIdentifying(false)) @@ -597,6 +597,28 @@ function BatchIdentifyDialog({ [collectionObjectIds, isIdentifying, loading, determination] ); + const handleCreateRecordSetAfterIdentify = React.useCallback((): void => { + if ( + isCreatingRecordSet || + identifiedCollectionObjectIds.length === 0 || + typeof createdRecordSet === 'object' + ) + return; + setIsCreatingRecordSet(true); + loading( + createBatchIdentifyRecordSet(identifiedCollectionObjectIds) + .then((recordSet) => { + if (typeof recordSet === 'object') setCreatedRecordSet(recordSet); + }) + .finally(() => setIsCreatingRecordSet(false)) + ); + }, [ + isCreatingRecordSet, + identifiedCollectionObjectIds, + createdRecordSet, + loading, + ]); + const previewExtraButtons = React.useMemo( () => ( <> @@ -617,20 +639,76 @@ function BatchIdentifyDialog({ if (showSuccessDialog) return ( - {commonText.close()}} - header={batchIdentifyText.batchIdentify()} - onClose={handleClose} - > -
-

{batchIdentifyText.successMessage()}

- {typeof createdRecordSet === 'object' ? ( - - {localized(createdRecordSet.name)} - - ) : undefined} -
-
+ <> + + {canCreateRecordSet && ( + + {queryText.createRecordSet({ + recordSetTable: recordSetLabel, + })} + + )} + setIsBrowseAfterIdentifyOpen(true)} + > + {queryText.browseInForms()} + + {commonText.close()} + + } + className={{ + container: dialogClassNames.narrowContainer, + }} + dimensionsKey={false} + header={batchIdentifyText.batchIdentify()} + icon={icons.clipboardCopy} + onClose={handleClose} + > +
+

{batchIdentifyText.successMessage()}

+ {typeof createdRecordSet === 'object' ? ( + + {localized(createdRecordSet.name)} + + ) : undefined} +
+
+ {isBrowseAfterIdentifyOpen && ( + + setIsBrowseAfterIdentifyOpen(false)} + onDelete={undefined} + onSaved={f.void} + onSlide={undefined} + /> + + )} + ); return ( @@ -640,12 +718,13 @@ function BatchIdentifyDialog({ step === 'catalogNumbers' ? ( <> {commonText.cancel()} - + setIsRecordSetDialogOpen(true)} > {recordSetLabel} - + ) : ( <> - setStep('catalogNumbers')}> + {commonText.close()} + + setStep('catalogNumbers')}> {commonText.back()} - + {batchIdentifyText.identify()} - {commonText.close()} ) } className={{ - container: dialogClassNames.extraWideContainer, + container: + step === 'catalogNumbers' + ? dialogClassNames.narrowContainer + : dialogClassNames.extraWideContainer, }} + dimensionsKey={`batch-identify-${step}`} header={batchIdentifyText.batchIdentify()} icon={icons.clipboardCopy} onClose={handleClose} @@ -683,12 +767,11 @@ function BatchIdentifyDialog({ {step === 'catalogNumbers' ? (

{batchIdentifyText.instructions()}

-

{catalogNumberLabel}

scheduleLiveValidation(true)} @@ -722,7 +805,6 @@ function BatchIdentifyDialog({
- + + +
From 924543187eaa36575ce7971f9a90d932e6967ca4 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 2 Mar 2026 06:42:43 +0000 Subject: [PATCH 07/26] Lint code with ESLint and Prettier Triggered by 1bd8af87fea2b0dfe0bbfb9c12588e50db687a60 on branch refs/heads/issue-7764 --- .../js_src/lib/components/BatchIdentify/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 5221e1822c0..18564a2bcd8 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useValidation } from '../../hooks/useValidation'; import { batchIdentifyText } from '../../localization/batchIdentify'; import { commonText } from '../../localization/common'; +import { queryText } from '../../localization/query'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; @@ -28,7 +29,6 @@ import { hasToolPermission } from '../Permissions/helpers'; import { ProtectedTable } from '../Permissions/PermissionDenied'; import type { QueryField } from '../QueryBuilder/helpers'; import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; -import { queryText } from '../../localization/query'; import { OverlayContext } from '../Router/Router'; import { useSearchDialog } from '../SearchDialog'; import { RecordSetsDialog } from '../Toolbar/RecordSets'; @@ -59,7 +59,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; + const tokens: readonly CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -85,7 +85,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: [number, number][] = []; + const ranges: readonly (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -207,7 +207,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: number[] = []; + const collectionObjectIds: readonly number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( From b86b02ba2aa6568f97b506558fcd89afe0c9e03c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:38:33 -0600 Subject: [PATCH 08/26] feat: add handling for multiple taxon trees --- specifyweb/backend/batch_identify/views.py | 128 +++++++++++++++++- .../lib/components/BatchIdentify/index.tsx | 68 +++++++++- .../js_src/lib/localization/batchIdentify.ts | 6 + .../js_src/lib/localization/resources.ts | 4 + 4 files changed, 197 insertions(+), 9 deletions(-) diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py index 6a5a9de3f24..9d588c7e32e 100644 --- a/specifyweb/backend/batch_identify/views.py +++ b/specifyweb/backend/batch_identify/views.py @@ -145,6 +145,7 @@ def _fetch_collection_objects_by_catalog_ranges( Collectionobject.objects.filter(collectionmemberid=collection_id) .exclude(catalognumber__isnull=True) .exclude(catalognumber='') + .select_related('collectionobjecttype__taxontreedef') .annotate(catalog_number_int=Cast('catalognumber', IntegerField())) .filter(_build_catalog_query(catalog_ranges)) .order_by('catalog_number_int', 'id') @@ -229,6 +230,96 @@ def _parse_collection_object_ids(request_data: dict[str, Any]) -> list[int]: return deduplicated_ids + +def _resolve_collection_object_taxon_tree_def_id( + collection_object: Collectionobject, + fallback_taxon_tree_def_id: int | None, +) -> int | None: + return ( + collection_object.collectionobjecttype.taxontreedef_id + if collection_object.collectionobjecttype is not None + else fallback_taxon_tree_def_id + ) + + +def _resolve_collection_object_taxon_tree_name( + collection_object: Collectionobject, + fallback_taxon_tree_name: str | None, +) -> str | None: + return ( + collection_object.collectionobjecttype.taxontreedef.name + if collection_object.collectionobjecttype is not None + else fallback_taxon_tree_name + ) + + +def _has_single_effective_collection_object_taxon_tree( + collection_objects: Iterable[Collectionobject], + fallback_taxon_tree_def_id: int | None, +) -> bool: + effective_taxon_tree_def_ids = { + _resolve_collection_object_taxon_tree_def_id( + collection_object, + fallback_taxon_tree_def_id, + ) + for collection_object in collection_objects + } + if len(effective_taxon_tree_def_ids) == 0: + return True + if None in effective_taxon_tree_def_ids: + return False + return len(effective_taxon_tree_def_ids) == 1 + + +def _build_taxon_tree_groups( + collection_objects: Iterable[Collectionobject], + fallback_taxon_tree_def_id: int | None, + fallback_taxon_tree_name: str | None, +) -> list[dict[str, Any]]: + grouped: dict[int | None, dict[str, Any]] = {} + for collection_object in collection_objects: + tree_def_id = _resolve_collection_object_taxon_tree_def_id( + collection_object, + fallback_taxon_tree_def_id, + ) + tree_name = _resolve_collection_object_taxon_tree_name( + collection_object, + fallback_taxon_tree_name, + ) + group = grouped.setdefault( + tree_def_id, + { + 'taxonTreeDefId': tree_def_id, + 'taxonTreeName': tree_name, + 'collectionObjectIds': [], + 'catalogNumbers': [], + 'collectionObjectTypeNames': set(), + }, + ) + group['collectionObjectIds'].append(collection_object.id) + if isinstance(collection_object.catalognumber, str): + group['catalogNumbers'].append(collection_object.catalognumber) + + collection_object_type_name = ( + collection_object.collectionobjecttype.name + if collection_object.collectionobjecttype is not None + else None + ) + if isinstance(collection_object_type_name, str): + group['collectionObjectTypeNames'].add(collection_object_type_name) + else: + group['collectionObjectTypeNames'].add('Default Collection Object Type') + + groups = sorted( + grouped.values(), + key=lambda group: (group['taxonTreeName'] is None, group['taxonTreeName'] or ''), + ) + for group in groups: + group['collectionObjectIds'] = sorted(group['collectionObjectIds']) + group['catalogNumbers'] = sorted(group['catalogNumbers']) + group['collectionObjectTypeNames'] = sorted(group['collectionObjectTypeNames']) + return groups + @login_maybe_required @require_POST def batch_identify_resolve(request: http.HttpRequest): @@ -254,6 +345,19 @@ def batch_identify_resolve(request: http.HttpRequest): include_current_determinations=not validate_only, max_results=_MAX_RESOLVE_COLLECTION_OBJECTS, ) + has_mixed_taxon_trees = not _has_single_effective_collection_object_taxon_tree( + collection_objects, + request.specify_collection.discipline.taxontreedef_id, + ) + taxon_tree_groups = _build_taxon_tree_groups( + collection_objects, + request.specify_collection.discipline.taxontreedef_id, + ( + request.specify_collection.discipline.taxontreedef.name + if request.specify_collection.discipline.taxontreedef is not None + else None + ), + ) matched_catalog_numbers = _fetch_matched_catalog_numbers( request.specify_collection.id, catalog_ranges ) @@ -274,6 +378,8 @@ def batch_identify_resolve(request: http.HttpRequest): 'collectionObjectIds': collection_object_ids, 'currentDeterminationIds': current_determination_ids, 'unmatchedCatalogNumbers': unmatched_catalog_numbers, + 'hasMixedTaxonTrees': has_mixed_taxon_trees, + 'taxonTreeGroups': taxon_tree_groups, } ) @@ -304,12 +410,15 @@ def batch_identify(request: http.HttpRequest): {'error': "'determination' must be an object."}, status=400 ) - existing_collection_object_ids = set( + collection_objects = list( Collectionobject.objects.filter( collectionmemberid=request.specify_collection.id, id__in=collection_object_ids, - ).values_list('id', flat=True) + ).select_related('collectionobjecttype__taxontreedef') ) + existing_collection_object_ids = { + collection_object.id for collection_object in collection_objects + } missing_collection_object_ids = [ collection_object_id for collection_object_id in collection_object_ids @@ -326,6 +435,21 @@ def batch_identify(request: http.HttpRequest): status=400, ) + if not _has_single_effective_collection_object_taxon_tree( + collection_objects, + request.specify_collection.discipline.taxontreedef_id, + ): + return http.JsonResponse( + { + 'error': ( + 'Selected collection objects must all use collection object' + ' types in the same taxon tree, or all default to the' + " discipline's taxon tree." + ) + }, + status=400, + ) + cleaned_payload = _sanitize_determination_payload(determination_payload) mark_as_current = cleaned_payload.get('isCurrent') is True if mark_as_current: diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 18564a2bcd8..5aaabb45240 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -4,6 +4,7 @@ import { useValidation } from '../../hooks/useValidation'; import { batchIdentifyText } from '../../localization/batchIdentify'; import { commonText } from '../../localization/common'; import { queryText } from '../../localization/query'; +import { resourcesText } from '../../localization/resources'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; @@ -37,6 +38,14 @@ type BatchIdentifyResolveResponse = { readonly collectionObjectIds: RA; readonly currentDeterminationIds: RA; readonly unmatchedCatalogNumbers: RA; + readonly hasMixedTaxonTrees: boolean; + readonly taxonTreeGroups: RA<{ + readonly taxonTreeDefId: number | null; + readonly taxonTreeName: string | null; + readonly collectionObjectIds: RA; + readonly catalogNumbers: RA; + readonly collectionObjectTypeNames: RA; + }>; }; type BatchIdentifySaveResponse = { @@ -59,7 +68,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: readonly CatalogToken[] = []; + const tokens: CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -85,7 +94,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: readonly (readonly [number, number])[] = []; + const ranges: [number, number][] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -207,7 +216,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: readonly number[] = []; + const collectionObjectIds: number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -299,6 +308,10 @@ function BatchIdentifyDialog({ const [unmatchedCatalogNumbers, setUnmatchedCatalogNumbers] = React.useState< RA >([]); + const [hasMixedTaxonTrees, setHasMixedTaxonTrees] = React.useState(false); + const [taxonTreeGroups, setTaxonTreeGroups] = React.useState< + BatchIdentifyResolveResponse['taxonTreeGroups'] + >([]); const [previewRunCount, setPreviewRunCount] = React.useState(0); const [selectedPreviewRows, setSelectedPreviewRows] = React.useState< ReadonlySet @@ -436,6 +449,8 @@ function BatchIdentifyDialog({ setValidatedCatalogNumbersKey(entriesKey); setResolvedCollectionObjectIds(data.collectionObjectIds); setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); + setHasMixedTaxonTrees(data.hasMixedTaxonTrees); + setTaxonTreeGroups(data.taxonTreeGroups); }) .catch(() => undefined) .finally(() => { @@ -490,6 +505,8 @@ function BatchIdentifyDialog({ if (catalogNumberRanges.length === 0) { setUnmatchedCatalogNumbers([]); setResolvedCollectionObjectIds([]); + setHasMixedTaxonTrees(false); + setTaxonTreeGroups([]); setValidatedCatalogNumbersKey(catalogNumbersKey); return; } @@ -525,6 +542,8 @@ function BatchIdentifyDialog({ (recordSetCollectionObjectIds) => { setUnmatchedCatalogNumbers([]); setResolvedCollectionObjectIds([]); + setHasMixedTaxonTrees(false); + setTaxonTreeGroups([]); setValidatedCatalogNumbersKey(''); proceedWithCollectionObjects(recordSetCollectionObjectIds); } @@ -535,10 +554,11 @@ function BatchIdentifyDialog({ ); const handleNext = React.useCallback((): void => { - if (catalogNumberRanges.length === 0 || isResolving || isLiveValidating) return; + if (catalogNumberRanges.length === 0 || isResolving) return; if (validatedCatalogNumbersKey === catalogNumbersKey) { if (unmatchedCatalogNumbers.length > 0) return; + if (hasMixedTaxonTrees) return; proceedWithCollectionObjects(resolvedCollectionObjectIds); return; } @@ -549,6 +569,9 @@ function BatchIdentifyDialog({ setValidatedCatalogNumbersKey(catalogNumbersKey); setResolvedCollectionObjectIds(data.collectionObjectIds); setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); + setHasMixedTaxonTrees(data.hasMixedTaxonTrees); + setTaxonTreeGroups(data.taxonTreeGroups); + if (data.hasMixedTaxonTrees) return; if (data.unmatchedCatalogNumbers.length > 0) { setCollectionObjectIds([]); setStep('catalogNumbers'); @@ -561,10 +584,10 @@ function BatchIdentifyDialog({ }, [ catalogNumberRanges, isResolving, - isLiveValidating, validatedCatalogNumbersKey, catalogNumbersKey, unmatchedCatalogNumbers, + hasMixedTaxonTrees, proceedWithCollectionObjects, resolvedCollectionObjectIds, loading, @@ -729,7 +752,6 @@ function BatchIdentifyDialog({ disabled={ catalogNumberRanges.length === 0 || isResolving || - isLiveValidating || unmatchedCatalogNumbers.length > 0 } onClick={handleNext} @@ -771,7 +793,7 @@ function BatchIdentifyDialog({ className="font-mono" forwardRef={validationRef} placeholder={batchIdentifyText.placeholder()} - rows={8} + rows={4} spellCheck={false} value={catalogNumbers} onBlur={(): void => scheduleLiveValidation(true)} @@ -779,10 +801,42 @@ function BatchIdentifyDialog({ setCatalogNumbers(value); setUnmatchedCatalogNumbers([]); setResolvedCollectionObjectIds([]); + setHasMixedTaxonTrees(false); + setTaxonTreeGroups([]); setValidatedCatalogNumbersKey(''); }} /> {isLiveValidating &&

{batchIdentifyText.validatingCatalogNumbers()}

} + {hasMixedTaxonTrees && ( +
+

{resourcesText.selectDeterminationTaxon()}

+ {taxonTreeGroups.map((group) => ( +
+

+ {(group.taxonTreeName ?? + batchIdentifyText.unknownTaxonTree()) + + ` (${group.collectionObjectIds.length})`} +

+

+ {commonText.colonLine({ + label: batchIdentifyText.collectionObjectTypes(), + value: group.collectionObjectTypeNames.join(', '), + })} +

+ + proceedWithCollectionObjects(group.collectionObjectIds) + } + > + {commonText.select()} + +
+ ))} +
+ )} {unmatchedCatalogNumbers.length > 0 && (

{batchIdentifyText.catalogNumbersNotFound()}

diff --git a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts index df25b0398ad..93971aa5ab2 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts @@ -38,4 +38,10 @@ export const batchIdentifyText = createDictionary({ previewQueryName: { 'en-us': 'Batch Identify Preview', }, + unknownTaxonTree: { + 'en-us': 'Unknown Taxon Tree', + }, + collectionObjectTypes: { + 'en-us': 'Collection Object Types', + }, }); diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index a8841282914..9b32f7683eb 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -944,6 +944,10 @@ export const resourcesText = createDictionary({ 'ru-ru': 'Подготовленный обмен не может быть удален.', 'uk-ua': 'Обмін, що готується, не можна видалити', }, + selectDeterminationTaxon: { + 'en-us': + 'Select one taxon-tree set to continue with:', + }, invalidDeterminationTaxon: { 'en-us': 'Determination does not belong to the taxon tree associated with the Collection Object Type', From 9fca626997d63c02202b8e352b2ccf454dc400c8 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:41:47 -0600 Subject: [PATCH 09/26] fix: show CO rows even without current dets --- .../frontend/js_src/lib/components/BatchIdentify/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 5aaabb45240..b9f85465a39 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -190,7 +190,7 @@ const buildPreviewFields = ( filters: [ { ...queryFilterDefaults, - type: 'true', + type: 'trueOrNull', startValue: '', }, ], From 8c75c2607b651ba15801c3330b0fa2e3c826a08c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:46:19 +0000 Subject: [PATCH 10/26] Lint code with ESLint and Prettier Triggered by 9fca626997d63c02202b8e352b2ccf454dc400c8 on branch refs/heads/issue-7764 --- .../js_src/lib/components/BatchIdentify/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index b9f85465a39..8c624de3c4f 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -68,7 +68,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; + const tokens: readonly CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -94,7 +94,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: [number, number][] = []; + const ranges: readonly (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -216,7 +216,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: number[] = []; + const collectionObjectIds: readonly number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -812,13 +812,13 @@ function BatchIdentifyDialog({

{resourcesText.selectDeterminationTaxon()}

{taxonTreeGroups.map((group) => (

- {(group.taxonTreeName ?? - batchIdentifyText.unknownTaxonTree()) + - ` (${group.collectionObjectIds.length})`} + {`${group.taxonTreeName ?? + batchIdentifyText.unknownTaxonTree() + } (${group.collectionObjectIds.length})`}

{commonText.colonLine({ From 867fe3e85dba68de5c23920031e79b608596b2ec Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:12:02 -0600 Subject: [PATCH 11/26] Handle multiple trees and reset determinations --- .../lib/components/BatchIdentify/index.tsx | 68 +++++++++++++------ 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index b9f85465a39..215e58e1dd3 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -17,7 +17,7 @@ import { Link } from '../Atoms/Link'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { fetchCollection } from '../DataModel/collection'; import type { SerializedResource } from '../DataModel/helperTypes'; -import { createResource } from '../DataModel/resource'; +import { createResource, getResourceApiUrl } from '../DataModel/resource'; import { serializeResource } from '../DataModel/serializers'; import { tables } from '../DataModel/tables'; import type { RecordSet } from '../DataModel/types'; @@ -28,6 +28,7 @@ import { AutoGrowTextArea } from '../Molecules/AutoGrowTextArea'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { hasToolPermission } from '../Permissions/helpers'; import { ProtectedTable } from '../Permissions/PermissionDenied'; +import { TreeDefinitionContext } from '../QueryComboBox/useTreeData'; import type { QueryField } from '../QueryBuilder/helpers'; import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; import { OverlayContext } from '../Router/Router'; @@ -260,6 +261,9 @@ const createBatchIdentifyRecordSet = async ( }); }; +const createDetermination = () => + new tables.Determination.Resource().set('isCurrent', true); + export function BatchIdentifyOverlay(): JSX.Element { const handleClose = React.useContext(OverlayContext); return ( @@ -312,13 +316,13 @@ function BatchIdentifyDialog({ const [taxonTreeGroups, setTaxonTreeGroups] = React.useState< BatchIdentifyResolveResponse['taxonTreeGroups'] >([]); + const [selectedTaxonTreeDefUri, setSelectedTaxonTreeDefUri] = + React.useState(undefined); const [previewRunCount, setPreviewRunCount] = React.useState(0); const [selectedPreviewRows, setSelectedPreviewRows] = React.useState< ReadonlySet >(new Set()); - const [determination] = React.useState( - () => new tables.Determination.Resource().set('isCurrent', true) - ); + const [determination, setDetermination] = React.useState(createDetermination); const [previewQuery] = React.useState(createBatchIdentifyPreviewQuery); const liveValidationRequestTokenRef = React.useRef(0); const liveValidationTimeoutRef = React.useRef< @@ -418,7 +422,13 @@ function BatchIdentifyDialog({ ); const proceedWithCollectionObjects = React.useCallback( - (resolvedIds: RA): void => { + (resolvedIds: RA, taxonTreeDefId?: number | null): void => { + setDetermination(createDetermination()); + setSelectedTaxonTreeDefUri( + typeof taxonTreeDefId === 'number' + ? getResourceApiUrl('TaxonTreeDef', taxonTreeDefId) + : undefined + ); setCollectionObjectIds(resolvedIds); setSelectedPreviewRows(new Set()); if (resolvedIds.length === 0) { @@ -431,6 +441,12 @@ function BatchIdentifyDialog({ [] ); + const handleBackToCatalogNumbers = React.useCallback((): void => { + setDetermination(createDetermination()); + setSelectedTaxonTreeDefUri(undefined); + setStep('catalogNumbers'); + }, []); + const runLiveValidation = React.useCallback( (entries: RA, entriesKey: string): void => { const requestToken = liveValidationRequestTokenRef.current + 1; @@ -559,7 +575,10 @@ function BatchIdentifyDialog({ if (validatedCatalogNumbersKey === catalogNumbersKey) { if (unmatchedCatalogNumbers.length > 0) return; if (hasMixedTaxonTrees) return; - proceedWithCollectionObjects(resolvedCollectionObjectIds); + proceedWithCollectionObjects( + resolvedCollectionObjectIds, + taxonTreeGroups[0]?.taxonTreeDefId + ); return; } @@ -577,7 +596,10 @@ function BatchIdentifyDialog({ setStep('catalogNumbers'); return; } - proceedWithCollectionObjects(data.collectionObjectIds); + proceedWithCollectionObjects( + data.collectionObjectIds, + data.taxonTreeGroups[0]?.taxonTreeDefId + ); }) .finally(() => setIsResolving(false)) ); @@ -588,6 +610,7 @@ function BatchIdentifyDialog({ catalogNumbersKey, unmatchedCatalogNumbers, hasMixedTaxonTrees, + taxonTreeGroups, proceedWithCollectionObjects, resolvedCollectionObjectIds, loading, @@ -763,7 +786,7 @@ function BatchIdentifyDialog({ <> {commonText.close()} - setStep('catalogNumbers')}> + {commonText.back()} - proceedWithCollectionObjects(group.collectionObjectIds) + proceedWithCollectionObjects( + group.collectionObjectIds, + group.taxonTreeDefId + ) } > {commonText.select()} @@ -858,17 +884,19 @@ function BatchIdentifyDialog({ )}

- + + +
Date: Mon, 2 Mar 2026 18:14:05 -0600 Subject: [PATCH 12/26] feat: use fullname instead --- .../frontend/js_src/lib/components/BatchIdentify/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index afbc7164297..9e560c65394 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -171,14 +171,14 @@ const buildPreviewFields = ( }, { id: 4, - mappingPath: ['determinations', '#1', 'preferredTaxon', 'name'], + mappingPath: ['determinations', '#1', 'preferredTaxon', 'fullname'], sortType: undefined, isDisplay: true, filters: [anyFilter], }, { id: 5, - mappingPath: ['determinations', '#1', 'taxon', 'name'], + mappingPath: ['determinations', '#1', 'taxon', 'fullname'], sortType: undefined, isDisplay: true, filters: [anyFilter], From 46e8a971af79f0652700ff16cb0223fb1c5f6f22 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:30:37 -0600 Subject: [PATCH 13/26] fix: CO search dialog stops returning other trees --- .../lib/components/BatchIdentify/index.tsx | 131 ++++++++++++++++-- .../lib/components/QueryComboBox/index.tsx | 3 +- .../lib/components/SearchDialog/index.tsx | 1 + 3 files changed, 119 insertions(+), 16 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 9e560c65394..7b46c2d79f6 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -17,7 +17,11 @@ import { Link } from '../Atoms/Link'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { fetchCollection } from '../DataModel/collection'; import type { SerializedResource } from '../DataModel/helperTypes'; -import { createResource, getResourceApiUrl } from '../DataModel/resource'; +import { + createResource, + getResourceApiUrl, + strictIdFromUrl, +} from '../DataModel/resource'; import { serializeResource } from '../DataModel/serializers'; import { tables } from '../DataModel/tables'; import type { RecordSet } from '../DataModel/types'; @@ -69,7 +73,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: readonly CatalogToken[] = []; + const tokens: CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -95,7 +99,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: readonly (readonly [number, number])[] = []; + const ranges: [number, number][] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -217,7 +221,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: readonly number[] = []; + const collectionObjectIds: number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -318,6 +322,8 @@ function BatchIdentifyDialog({ >([]); const [selectedTaxonTreeDefUri, setSelectedTaxonTreeDefUri] = React.useState(undefined); + const [searchTreeCollectionObjectTypeIds, setSearchTreeCollectionObjectTypeIds] = + React.useState | undefined>(undefined); const [previewRunCount, setPreviewRunCount] = React.useState(0); const [selectedPreviewRows, setSelectedPreviewRows] = React.useState< ReadonlySet @@ -348,22 +354,112 @@ function BatchIdentifyDialog({ [catalogNumberEntries] ); + React.useEffect(() => { + if (selectedTaxonTreeDefUri === undefined) { + setSearchTreeCollectionObjectTypeIds(undefined); + return; + } + const selectedTaxonTreeDefId = strictIdFromUrl(selectedTaxonTreeDefUri); + let isCancelled = false; + setSearchTreeCollectionObjectTypeIds(undefined); + void fetchCollection('CollectionObjectType', { + domainFilter: true, + limit: 0, + orderBy: 'id', + taxonTreeDef: selectedTaxonTreeDefId, + }) + .then(({ records }) => { + if (isCancelled) return; + setSearchTreeCollectionObjectTypeIds(records.map(({ id }) => id)); + }) + .catch(() => { + if (isCancelled) return; + setSearchTreeCollectionObjectTypeIds([]); + }); + return () => { + isCancelled = true; + }; + }, [selectedTaxonTreeDefUri]); + + const searchDialogExtraFilters = React.useMemo(() => { + if (selectedTaxonTreeDefUri === undefined) return undefined; + if (searchTreeCollectionObjectTypeIds === undefined) return undefined; + return [ + { + field: 'collectionObjectType', + queryBuilderFieldPath: ['collectionObjectType', 'id'], + isRelationship: true, + isNot: false, + operation: 'in', + value: + searchTreeCollectionObjectTypeIds.length === 0 + ? '-1' + : searchTreeCollectionObjectTypeIds.join(','), + }, + ] as const; + }, [selectedTaxonTreeDefUri, searchTreeCollectionObjectTypeIds]); + + const isSearchTreeFilterLoading = + selectedTaxonTreeDefUri !== undefined && + searchTreeCollectionObjectTypeIds === undefined; + const handleAddCollectionObjects = React.useCallback( (resources: RA<{ readonly id: number }>): void => { - const mergedCollectionObjectIds = f.unique([ - ...collectionObjectIds, - ...resources.map(({ id }) => id), - ]); - if (mergedCollectionObjectIds.length === collectionObjectIds.length) return; - setCollectionObjectIds(mergedCollectionObjectIds); - setSelectedPreviewRows(new Set()); - setPreviewRunCount((count) => count + 1); + const selectedIds = resources.map(({ id }) => id); + if (selectedIds.length === 0) return; + + const applyIds = (candidateIds: RA): void => { + const mergedCollectionObjectIds = f.unique([ + ...collectionObjectIds, + ...candidateIds, + ]); + if (mergedCollectionObjectIds.length === collectionObjectIds.length) return; + setCollectionObjectIds(mergedCollectionObjectIds); + setSelectedPreviewRows(new Set()); + setPreviewRunCount((count) => count + 1); + }; + + if ( + selectedTaxonTreeDefUri === undefined || + searchTreeCollectionObjectTypeIds === undefined + ) { + applyIds(selectedIds); + return; + } + + const allowedTypeIds = new Set(searchTreeCollectionObjectTypeIds); + loading( + fetchCollection( + 'CollectionObject', + { + domainFilter: true, + limit: 0, + }, + { + id__in: selectedIds.join(','), + } + ).then(({ records }) => { + const allowedIds = records + .filter(({ collectionObjectType }) => { + if (typeof collectionObjectType !== 'string') return false; + const typeId = strictIdFromUrl(collectionObjectType); + return allowedTypeIds.has(typeId); + }) + .map(({ id }) => id); + applyIds(allowedIds); + }) + ); }, - [collectionObjectIds] + [ + collectionObjectIds, + loading, + searchTreeCollectionObjectTypeIds, + selectedTaxonTreeDefUri, + ] ); const { searchDialog, showSearchDialog } = useSearchDialog({ - extraFilters: undefined, + extraFilters: searchDialogExtraFilters as never, forceCollection: undefined, multiple: true, table: tables.CollectionObject, @@ -668,7 +764,11 @@ function BatchIdentifyDialog({ const previewExtraButtons = React.useMemo( () => ( <> - + + ( { props.onSelected(records); props.onClose(); From 0cddfd1c6d0ec159cd5260363797351088f47003 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:38:11 +0000 Subject: [PATCH 14/26] Lint code with ESLint and Prettier Triggered by 46e8a971af79f0652700ff16cb0223fb1c5f6f22 on branch refs/heads/issue-7764 --- .../js_src/lib/components/BatchIdentify/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 7b46c2d79f6..9125315c6f0 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -32,9 +32,9 @@ import { AutoGrowTextArea } from '../Molecules/AutoGrowTextArea'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { hasToolPermission } from '../Permissions/helpers'; import { ProtectedTable } from '../Permissions/PermissionDenied'; -import { TreeDefinitionContext } from '../QueryComboBox/useTreeData'; import type { QueryField } from '../QueryBuilder/helpers'; import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; +import { TreeDefinitionContext } from '../QueryComboBox/useTreeData'; import { OverlayContext } from '../Router/Router'; import { useSearchDialog } from '../SearchDialog'; import { RecordSetsDialog } from '../Toolbar/RecordSets'; @@ -73,7 +73,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; + const tokens: readonly CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -99,7 +99,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: [number, number][] = []; + const ranges: readonly (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -221,7 +221,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: number[] = []; + const collectionObjectIds: readonly number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( From e609eeffddea6e5dbd10abcdd2f133e2fb7f979d Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 2 Mar 2026 21:44:42 -0600 Subject: [PATCH 15/26] add openapi decorators --- specifyweb/backend/batch_identify/views.py | 203 +++++++++++++++++++-- 1 file changed, 192 insertions(+), 11 deletions(-) diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py index 9d588c7e32e..441abb15fcc 100644 --- a/specifyweb/backend/batch_identify/views.py +++ b/specifyweb/backend/batch_identify/views.py @@ -13,7 +13,7 @@ from specifyweb.specify.api.calculated_fields import calculate_extra_fields from specifyweb.specify.api.crud import post_resource from specifyweb.specify.models import Collectionobject, Determination -from specifyweb.specify.views import login_maybe_required +from specifyweb.specify.views import login_maybe_required, openapi _RESOURCE_URI_ID_RE = re.compile(r'/(\d+)/?$') _MAX_RESOLVE_COLLECTION_OBJECTS = 1000 @@ -162,9 +162,7 @@ def _fetch_collection_objects_by_catalog_ranges( queryset = queryset[:max_results] return list(queryset) -def _fetch_matched_catalog_numbers( - collection_id: int, catalog_ranges: Iterable[tuple[int, int]] -) -> set[int]: +def _fetch_matched_catalog_numbers(collection_id: int, catalog_ranges: Iterable[tuple[int, int]]) -> set[int]: return { catalog_number for catalog_number in Collectionobject.objects.filter( @@ -178,9 +176,7 @@ def _fetch_matched_catalog_numbers( if isinstance(catalog_number, int) } -def _extract_current_determination_ids( - collection_objects: Iterable[Collectionobject], -) -> list[int]: +def _extract_current_determination_ids(collection_objects: Iterable[Collectionobject]) -> list[int]: current_determination_ids: list[int] = [] for collection_object in collection_objects: determinations = getattr(collection_object, 'prefetched_determinations', []) @@ -230,7 +226,6 @@ def _parse_collection_object_ids(request_data: dict[str, Any]) -> list[int]: return deduplicated_ids - def _resolve_collection_object_taxon_tree_def_id( collection_object: Collectionobject, fallback_taxon_tree_def_id: int | None, @@ -241,7 +236,6 @@ def _resolve_collection_object_taxon_tree_def_id( else fallback_taxon_tree_def_id ) - def _resolve_collection_object_taxon_tree_name( collection_object: Collectionobject, fallback_taxon_tree_name: str | None, @@ -252,7 +246,6 @@ def _resolve_collection_object_taxon_tree_name( else fallback_taxon_tree_name ) - def _has_single_effective_collection_object_taxon_tree( collection_objects: Iterable[Collectionobject], fallback_taxon_tree_def_id: int | None, @@ -270,7 +263,6 @@ def _has_single_effective_collection_object_taxon_tree( return False return len(effective_taxon_tree_def_ids) == 1 - def _build_taxon_tree_groups( collection_objects: Iterable[Collectionobject], fallback_taxon_tree_def_id: int | None, @@ -320,6 +312,122 @@ def _build_taxon_tree_groups( group['collectionObjectTypeNames'] = sorted(group['collectionObjectTypeNames']) return groups +@openapi( + schema={ + 'post': { + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'catalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'validateOnly': {'type': 'boolean'}, + }, + 'required': ['catalogNumbers'], + 'additionalProperties': False, + } + } + }, + }, + 'responses': { + '200': { + 'description': ( + 'Resolved collection objects and validation details for' + ' catalog number input.' + ), + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'currentDeterminationIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'unmatchedCatalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'hasMixedTaxonTrees': {'type': 'boolean'}, + 'taxonTreeGroups': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'taxonTreeDefId': { + 'oneOf': [ + {'type': 'integer'}, + {'type': 'null'}, + ] + }, + 'taxonTreeName': { + 'oneOf': [ + {'type': 'string'}, + {'type': 'null'}, + ] + }, + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'catalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'collectionObjectTypeNames': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + 'required': [ + 'taxonTreeDefId', + 'taxonTreeName', + 'collectionObjectIds', + 'catalogNumbers', + 'collectionObjectTypeNames', + ], + 'additionalProperties': False, + }, + }, + }, + 'required': [ + 'collectionObjectIds', + 'currentDeterminationIds', + 'unmatchedCatalogNumbers', + 'hasMixedTaxonTrees', + 'taxonTreeGroups', + ], + 'additionalProperties': False, + } + } + }, + }, + '400': { + 'description': 'Invalid request payload.', + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': {'error': {'type': 'string'}}, + 'required': ['error'], + 'additionalProperties': False, + } + } + }, + }, + }, + } + } +) @login_maybe_required @require_POST def batch_identify_resolve(request: http.HttpRequest): @@ -383,6 +491,79 @@ def batch_identify_resolve(request: http.HttpRequest): } ) +@openapi( + schema={ + 'post': { + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'determination': { + 'type': 'object', + 'additionalProperties': True, + }, + }, + 'required': ['collectionObjectIds', 'determination'], + 'additionalProperties': False, + } + } + }, + }, + 'responses': { + '200': { + 'description': ( + 'Created determination records for all selected collection' + ' objects.' + ), + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'createdCount': {'type': 'integer'}, + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'determinationIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + }, + 'required': [ + 'createdCount', + 'collectionObjectIds', + 'determinationIds', + ], + 'additionalProperties': False, + } + } + }, + }, + '400': { + 'description': 'Invalid request payload.', + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': {'error': {'type': 'string'}}, + 'required': ['error'], + 'additionalProperties': False, + } + } + }, + }, + }, + } + } +) @login_maybe_required @require_POST @transaction.atomic From 48d4949c1c193829751c0e66c91a2f21556e4578 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 2 Mar 2026 22:37:11 -0600 Subject: [PATCH 16/26] add filter_collection_objects_to_majority_type to prevent differing CO types in batch identify --- specifyweb/backend/batch_identify/views.py | 34 ++++++++++++++++++ .../lib/components/BatchIdentify/index.tsx | 36 ++++++++++++++++--- .../js_src/lib/localization/batchIdentify.ts | 3 ++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py index 441abb15fcc..7ee6d685d92 100644 --- a/specifyweb/backend/batch_identify/views.py +++ b/specifyweb/backend/batch_identify/views.py @@ -1,6 +1,7 @@ import json import re from collections.abc import Iterable +from collections import Counter from typing import Any, Literal from django import http @@ -203,6 +204,29 @@ def _extract_current_determination_ids(collection_objects: Iterable[Collectionob current_determination_ids.append(int(current_determination_match.group(1))) return current_determination_ids +def _filter_collection_objects_to_majority_type( + collection_objects: Iterable[Collectionobject], +) -> tuple[list[Collectionobject], list[str]]: + collection_objects_list = list(collection_objects) + if len(collection_objects_list) <= 1: + return collection_objects_list, [] + + type_counts = Counter( + collection_object.collectionobjecttype_id + for collection_object in collection_objects_list + ) + majority_type_id = max(type_counts, key=type_counts.get) + + retained_collection_objects: list[Collectionobject] = [] + differing_type_catalog_numbers: list[str] = [] + for collection_object in collection_objects_list: + if collection_object.collectionobjecttype_id == majority_type_id: + retained_collection_objects.append(collection_object) + elif isinstance(collection_object.catalognumber, str): + differing_type_catalog_numbers.append(collection_object.catalognumber) + + return retained_collection_objects, differing_type_catalog_numbers + def _parse_collection_object_ids(request_data: dict[str, Any]) -> list[int]: collection_object_ids = request_data.get('collectionObjectIds') if not isinstance(collection_object_ids, list): @@ -357,6 +381,10 @@ def _build_taxon_tree_groups( 'type': 'array', 'items': {'type': 'string'}, }, + 'differingTypeCatalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, 'hasMixedTaxonTrees': {'type': 'boolean'}, 'taxonTreeGroups': { 'type': 'array', @@ -403,6 +431,7 @@ def _build_taxon_tree_groups( 'collectionObjectIds', 'currentDeterminationIds', 'unmatchedCatalogNumbers', + 'differingTypeCatalogNumbers', 'hasMixedTaxonTrees', 'taxonTreeGroups', ], @@ -453,6 +482,7 @@ def batch_identify_resolve(request: http.HttpRequest): include_current_determinations=not validate_only, max_results=_MAX_RESOLVE_COLLECTION_OBJECTS, ) + collection_objects, differing_type_catalog_numbers = _filter_collection_objects_to_majority_type(collection_objects) has_mixed_taxon_trees = not _has_single_effective_collection_object_taxon_tree( collection_objects, request.specify_collection.discipline.taxontreedef_id, @@ -486,6 +516,7 @@ def batch_identify_resolve(request: http.HttpRequest): 'collectionObjectIds': collection_object_ids, 'currentDeterminationIds': current_determination_ids, 'unmatchedCatalogNumbers': unmatched_catalog_numbers, + 'differingTypeCatalogNumbers': differing_type_catalog_numbers, 'hasMixedTaxonTrees': has_mixed_taxon_trees, 'taxonTreeGroups': taxon_tree_groups, } @@ -616,6 +647,9 @@ def batch_identify(request: http.HttpRequest): status=400, ) + collection_objects, _ = _filter_collection_objects_to_majority_type(collection_objects) + collection_object_ids = [collection_object.id for collection_object in collection_objects] + if not _has_single_effective_collection_object_taxon_tree( collection_objects, request.specify_collection.discipline.taxontreedef_id, diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 9125315c6f0..d5956680b1f 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -43,6 +43,7 @@ type BatchIdentifyResolveResponse = { readonly collectionObjectIds: RA; readonly currentDeterminationIds: RA; readonly unmatchedCatalogNumbers: RA; + readonly differingTypeCatalogNumbers: RA; readonly hasMixedTaxonTrees: boolean; readonly taxonTreeGroups: RA<{ readonly taxonTreeDefId: number | null; @@ -73,7 +74,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: readonly CatalogToken[] = []; + const tokens: CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -99,7 +100,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: readonly (readonly [number, number])[] = []; + const ranges: Array = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -221,7 +222,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: readonly number[] = []; + const collectionObjectIds: number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -316,6 +317,8 @@ function BatchIdentifyDialog({ const [unmatchedCatalogNumbers, setUnmatchedCatalogNumbers] = React.useState< RA >([]); + const [differingTypeCatalogNumbers, setDifferingTypeCatalogNumbers] = + React.useState>([]); const [hasMixedTaxonTrees, setHasMixedTaxonTrees] = React.useState(false); const [taxonTreeGroups, setTaxonTreeGroups] = React.useState< BatchIdentifyResolveResponse['taxonTreeGroups'] @@ -561,6 +564,7 @@ function BatchIdentifyDialog({ setValidatedCatalogNumbersKey(entriesKey); setResolvedCollectionObjectIds(data.collectionObjectIds); setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); + setDifferingTypeCatalogNumbers(data.differingTypeCatalogNumbers); setHasMixedTaxonTrees(data.hasMixedTaxonTrees); setTaxonTreeGroups(data.taxonTreeGroups); }) @@ -616,6 +620,7 @@ function BatchIdentifyDialog({ if (catalogNumberRanges.length === 0) { setUnmatchedCatalogNumbers([]); + setDifferingTypeCatalogNumbers([]); setResolvedCollectionObjectIds([]); setHasMixedTaxonTrees(false); setTaxonTreeGroups([]); @@ -653,6 +658,7 @@ function BatchIdentifyDialog({ fetchRecordSetCollectionObjectIds(recordSet.id).then( (recordSetCollectionObjectIds) => { setUnmatchedCatalogNumbers([]); + setDifferingTypeCatalogNumbers([]); setResolvedCollectionObjectIds([]); setHasMixedTaxonTrees(false); setTaxonTreeGroups([]); @@ -670,6 +676,7 @@ function BatchIdentifyDialog({ if (validatedCatalogNumbersKey === catalogNumbersKey) { if (unmatchedCatalogNumbers.length > 0) return; + if (differingTypeCatalogNumbers.length > 0) return; if (hasMixedTaxonTrees) return; proceedWithCollectionObjects( resolvedCollectionObjectIds, @@ -684,9 +691,11 @@ function BatchIdentifyDialog({ setValidatedCatalogNumbersKey(catalogNumbersKey); setResolvedCollectionObjectIds(data.collectionObjectIds); setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); + setDifferingTypeCatalogNumbers(data.differingTypeCatalogNumbers); setHasMixedTaxonTrees(data.hasMixedTaxonTrees); setTaxonTreeGroups(data.taxonTreeGroups); if (data.hasMixedTaxonTrees) return; + if (data.differingTypeCatalogNumbers.length > 0) return; if (data.unmatchedCatalogNumbers.length > 0) { setCollectionObjectIds([]); setStep('catalogNumbers'); @@ -705,6 +714,7 @@ function BatchIdentifyDialog({ validatedCatalogNumbersKey, catalogNumbersKey, unmatchedCatalogNumbers, + differingTypeCatalogNumbers, hasMixedTaxonTrees, taxonTreeGroups, proceedWithCollectionObjects, @@ -876,7 +886,8 @@ function BatchIdentifyDialog({ disabled={ catalogNumberRanges.length === 0 || isResolving || - unmatchedCatalogNumbers.length > 0 + unmatchedCatalogNumbers.length > 0 || + differingTypeCatalogNumbers.length > 0 } onClick={handleNext} > @@ -924,6 +935,7 @@ function BatchIdentifyDialog({ onValueChange={(value): void => { setCatalogNumbers(value); setUnmatchedCatalogNumbers([]); + setDifferingTypeCatalogNumbers([]); setResolvedCollectionObjectIds([]); setHasMixedTaxonTrees(false); setTaxonTreeGroups([]); @@ -972,6 +984,14 @@ function BatchIdentifyDialog({ ))}
)} + {differingTypeCatalogNumbers.length > 0 && ( +
+

{batchIdentifyText.catalogNumbersDifferentType()}

+ {differingTypeCatalogNumbers.map((catalogNumber, index) => ( +

{catalogNumber}

+ ))} +
+ )}
) : (
@@ -983,6 +1003,14 @@ function BatchIdentifyDialog({ ))}
)} + {differingTypeCatalogNumbers.length > 0 && ( +
+

{batchIdentifyText.catalogNumbersDifferentType()}

+ {differingTypeCatalogNumbers.map((catalogNumber, index) => ( +

{catalogNumber}

+ ))} +
+ )}
diff --git a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts index 93971aa5ab2..213ce4a427f 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts @@ -17,6 +17,9 @@ export const batchIdentifyText = createDictionary({ catalogNumbersNotFound: { 'en-us': 'Catalog Numbers Not Found', }, + catalogNumbersDifferentType: { + 'en-us': 'Catalog Numbers Ignored Due To Different Collection Object Type', + }, identify: { 'en-us': 'Identify', }, From 26ad433036f2f7de7c21f49fb07517c9af19a057 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 3 Mar 2026 04:41:13 +0000 Subject: [PATCH 17/26] Lint code with ESLint and Prettier Triggered by 48d4949c1c193829751c0e66c91a2f21556e4578 on branch refs/heads/issue-7764 --- .../frontend/js_src/lib/components/BatchIdentify/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index d5956680b1f..5c3cb61c843 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -74,7 +74,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; + const tokens: readonly CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -100,7 +100,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: Array = []; + const ranges: readonly (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -222,7 +222,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: number[] = []; + const collectionObjectIds: readonly number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( From 857c2ae9cc2a83ca43daff5ba099903b77ac7b08 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 2 Mar 2026 23:57:09 -0600 Subject: [PATCH 18/26] add support for CatalogNumberAlphaNumByYear --- specifyweb/backend/batch_identify/views.py | 221 +++++++++++++++++---- 1 file changed, 183 insertions(+), 38 deletions(-) diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py index 7ee6d685d92..4a6e0f0bdfa 100644 --- a/specifyweb/backend/batch_identify/views.py +++ b/specifyweb/backend/batch_identify/views.py @@ -7,7 +7,7 @@ from django import http from django.db import transaction from django.db.models import IntegerField, Prefetch, Q -from django.db.models.functions import Cast +from django.db.models.functions import Cast, Right, Substr from django.views.decorators.http import require_POST from specifyweb.backend.permissions.permissions import check_table_permissions @@ -17,7 +17,19 @@ from specifyweb.specify.views import login_maybe_required, openapi _RESOURCE_URI_ID_RE = re.compile(r'/(\d+)/?$') +_YEAR_CATALOG_NUMBER_DELIMITERS = '-/|._:; *$%#@' +_YEAR_CATALOG_NUMBER_DELIMITER_CLASS = re.escape(_YEAR_CATALOG_NUMBER_DELIMITERS) +_STORED_YEAR_CATALOG_NUMBER_RE = re.compile( + rf'^(?P\d{{4}})[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+(?P\d{{6}})$' +) +_ENTRY_YEAR_CATALOG_NUMBER_RE = re.compile( + rf'(?\d{{4}})[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+(?P\d{{6}})(?!\d)' +) _MAX_RESOLVE_COLLECTION_OBJECTS = 1000 +_NUMERIC_CATALOG_NUMBER_QUERY_REGEX = r'^[0-9]+$' +_YEAR_BASED_CATALOG_NUMBER_QUERY_REGEX = ( + rf'^[0-9]{{4}}[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+[0-9]{{6}}$' +) _METADATA_KEYS = { 'id', 'resource_uri', @@ -49,15 +61,50 @@ def _tokenize_catalog_entry(entry: str) -> list[CatalogToken]: return tokens -def _parse_catalog_number_ranges(entries: Iterable[str]) -> list[tuple[int, int]]: - ranges: list[tuple[int, int]] = [] +def _parse_catalog_number_requests( + entries: Iterable[str], +) -> tuple[list[tuple[int, int]], dict[int, list[tuple[int, int]]]]: + numeric_ranges: list[tuple[int, int]] = [] + year_based_ranges: dict[int, list[tuple[int, int]]] = {} for raw_entry in entries: entry = raw_entry.strip() if entry == '': continue - tokens = _tokenize_catalog_entry(entry) + year_matches = list(_ENTRY_YEAR_CATALOG_NUMBER_RE.finditer(entry)) + if len(year_matches) > 0: + index = 0 + while index < len(year_matches): + current_match = year_matches[index] + year = int(current_match.group('year')) + start = int(current_match.group('number')) + end = start + + if index + 1 < len(year_matches): + next_match = year_matches[index + 1] + if ( + int(next_match.group('year')) == year + and '-' in entry[current_match.end() : next_match.start()] + ): + end = int(next_match.group('number')) + index += 1 + + if start > end: + start, end = end, start + year_based_ranges.setdefault(year, []).append((start, end)) + index += 1 + + entry_segments: list[str] = [] + cursor = 0 + for match in year_matches: + entry_segments.append(entry[cursor : match.start()]) + entry_segments.append(' ' * (match.end() - match.start())) + cursor = match.end() + entry_segments.append(entry[cursor:]) + + numeric_entry = ''.join(entry_segments) + tokens = _tokenize_catalog_entry(numeric_entry) if not any(isinstance(token, int) for token in tokens): continue @@ -82,39 +129,95 @@ def _parse_catalog_number_ranges(entries: Iterable[str]) -> list[tuple[int, int] if start > end: start, end = end, start - ranges.append((start, end)) + numeric_ranges.append((start, end)) - if len(ranges) == 0: + if len(numeric_ranges) == 0 and len(year_based_ranges) == 0: raise ValueError('Provide at least one catalog number.') - return ranges + return numeric_ranges, year_based_ranges def _build_catalog_query(ranges: Iterable[tuple[int, int]]) -> Q: query = Q() for start, end in ranges: if start == end: - query |= Q(catalog_number_int=start) + query |= Q( + catalognumber__regex=_NUMERIC_CATALOG_NUMBER_QUERY_REGEX, + catalog_number_int=start, + ) else: - query |= Q(catalog_number_int__gte=start, catalog_number_int__lte=end) + query |= Q( + catalognumber__regex=_NUMERIC_CATALOG_NUMBER_QUERY_REGEX, + catalog_number_int__gte=start, + catalog_number_int__lte=end, + ) + return query + +def _build_year_based_catalog_query( + year_based_ranges: dict[int, list[tuple[int, int]]] +) -> Q: + query = Q() + for year, ranges in year_based_ranges.items(): + sequence_query = Q() + for start, end in ranges: + if start == end: + sequence_query |= Q(catalog_sequence_int=start) + else: + sequence_query |= Q( + catalog_sequence_int__gte=start, catalog_sequence_int__lte=end + ) + query |= ( + Q( + catalognumber__regex=_YEAR_BASED_CATALOG_NUMBER_QUERY_REGEX, + catalog_year_int=year, + ) + & sequence_query + ) + return query + +def _build_catalog_number_query( + numeric_ranges: list[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], +) -> Q: + query = Q() + if len(numeric_ranges) > 0: + query |= _build_catalog_query(numeric_ranges) + if len(year_based_ranges) > 0: + query |= _build_year_based_catalog_query(year_based_ranges) return query def _find_unmatched_catalog_numbers( - ranges: Iterable[tuple[int, int]], matched_catalog_numbers: Iterable[int] + numeric_ranges: Iterable[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], + matched_catalog_numbers: set[int], + matched_year_based_catalog_numbers: set[tuple[int, int]], ) -> list[str]: requested_numbers: set[int] = set() - for start, end in ranges: + for start, end in numeric_ranges: requested_numbers.update(range(start, end + 1)) - matched_numbers = { - catalog_number - for catalog_number in matched_catalog_numbers - if isinstance(catalog_number, int) - } + requested_year_based_numbers: set[tuple[int, int]] = set() + for year, ranges in year_based_ranges.items(): + for start, end in ranges: + requested_year_based_numbers.update( + (year, number) for number in range(start, end + 1) + ) - return sorted( - (str(number) for number in requested_numbers - matched_numbers), key=int + unmatched_numeric_numbers = sorted( + requested_numbers - matched_catalog_numbers, + key=int, + ) + unmatched_year_based_numbers = sorted( + requested_year_based_numbers - matched_year_based_catalog_numbers ) + return [ + *(str(number) for number in unmatched_numeric_numbers), + *( + f'{year:04d}-{number:06d}' + for year, number in unmatched_year_based_numbers + ), + ] + def _sanitize_determination_payload(payload: dict[str, Any]) -> dict[str, Any]: return { key: value @@ -136,9 +239,10 @@ def _parse_validate_only(request_data: dict[str, Any]) -> bool: raise ValueError("'validateOnly' must be a boolean.") return validate_only -def _fetch_collection_objects_by_catalog_ranges( +def _fetch_collection_objects_by_catalog_requests( collection_id: int, - catalog_ranges: Iterable[tuple[int, int]], + numeric_ranges: list[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], include_current_determinations: bool = True, max_results: int | None = None, ) -> list[Collectionobject]: @@ -147,10 +251,17 @@ def _fetch_collection_objects_by_catalog_ranges( .exclude(catalognumber__isnull=True) .exclude(catalognumber='') .select_related('collectionobjecttype__taxontreedef') - .annotate(catalog_number_int=Cast('catalognumber', IntegerField())) - .filter(_build_catalog_query(catalog_ranges)) - .order_by('catalog_number_int', 'id') ) + if len(numeric_ranges) > 0: + queryset = queryset.annotate(catalog_number_int=Cast('catalognumber', IntegerField())) + if len(year_based_ranges) > 0: + queryset = queryset.annotate( + catalog_year_int=Cast(Substr('catalognumber', 1, 4), IntegerField()), + catalog_sequence_int=Cast(Right('catalognumber', 6), IntegerField()), + ) + queryset = queryset.filter( + _build_catalog_number_query(numeric_ranges, year_based_ranges) + ).order_by('catalognumber', 'id') if include_current_determinations: queryset = queryset.prefetch_related( Prefetch( @@ -163,19 +274,44 @@ def _fetch_collection_objects_by_catalog_ranges( queryset = queryset[:max_results] return list(queryset) -def _fetch_matched_catalog_numbers(collection_id: int, catalog_ranges: Iterable[tuple[int, int]]) -> set[int]: - return { - catalog_number - for catalog_number in Collectionobject.objects.filter( +def _fetch_matched_catalog_number_identifiers( + collection_id: int, + numeric_ranges: list[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], +) -> tuple[set[int], set[tuple[int, int]]]: + queryset = ( + Collectionobject.objects.filter( collectionmemberid=collection_id ) .exclude(catalognumber__isnull=True) .exclude(catalognumber='') - .annotate(catalog_number_int=Cast('catalognumber', IntegerField())) - .filter(_build_catalog_query(catalog_ranges)) - .values_list('catalog_number_int', flat=True) - if isinstance(catalog_number, int) - } + ) + if len(numeric_ranges) > 0: + queryset = queryset.annotate(catalog_number_int=Cast('catalognumber', IntegerField())) + if len(year_based_ranges) > 0: + queryset = queryset.annotate( + catalog_year_int=Cast(Substr('catalognumber', 1, 4), IntegerField()), + catalog_sequence_int=Cast(Right('catalognumber', 6), IntegerField()), + ) + catalog_numbers = queryset.filter( + _build_catalog_number_query(numeric_ranges, year_based_ranges) + ).values_list('catalognumber', flat=True) + + matched_numeric_numbers: set[int] = set() + matched_year_based_numbers: set[tuple[int, int]] = set() + for catalog_number in catalog_numbers: + if not isinstance(catalog_number, str): + continue + if catalog_number.isdigit(): + matched_numeric_numbers.add(int(catalog_number)) + year_match = _STORED_YEAR_CATALOG_NUMBER_RE.match(catalog_number) + if year_match is None: + continue + matched_year_based_numbers.add( + (int(year_match.group('year')), int(year_match.group('number'))) + ) + + return matched_numeric_numbers, matched_year_based_numbers def _extract_current_determination_ids(collection_objects: Iterable[Collectionobject]) -> list[int]: current_determination_ids: list[int] = [] @@ -471,14 +607,15 @@ def batch_identify_resolve(request: http.HttpRequest): try: catalog_numbers = _parse_catalog_numbers(request_data) - catalog_ranges = _parse_catalog_number_ranges(catalog_numbers) + numeric_ranges, year_based_ranges = _parse_catalog_number_requests(catalog_numbers) validate_only = _parse_validate_only(request_data) except ValueError as error: return http.JsonResponse({'error': str(error)}, status=400) - collection_objects = _fetch_collection_objects_by_catalog_ranges( + collection_objects = _fetch_collection_objects_by_catalog_requests( request.specify_collection.id, - catalog_ranges, + numeric_ranges, + year_based_ranges, include_current_determinations=not validate_only, max_results=_MAX_RESOLVE_COLLECTION_OBJECTS, ) @@ -496,8 +633,13 @@ def batch_identify_resolve(request: http.HttpRequest): else None ), ) - matched_catalog_numbers = _fetch_matched_catalog_numbers( - request.specify_collection.id, catalog_ranges + ( + matched_catalog_numbers, + matched_year_based_catalog_numbers, + ) = _fetch_matched_catalog_number_identifiers( + request.specify_collection.id, + numeric_ranges, + year_based_ranges, ) collection_object_ids = [ @@ -509,7 +651,10 @@ def batch_identify_resolve(request: http.HttpRequest): else [] ) unmatched_catalog_numbers = _find_unmatched_catalog_numbers( - catalog_ranges, matched_catalog_numbers + numeric_ranges, + year_based_ranges, + matched_catalog_numbers, + matched_year_based_catalog_numbers, ) return http.JsonResponse( { From 6f8dc25d3178814da6e2d256146136f0a8a23e44 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 3 Mar 2026 22:11:25 -0600 Subject: [PATCH 19/26] Revert "add filter_collection_objects_to_majority_type to prevent differing CO types in batch identify" This reverts commit 48d4949c1c193829751c0e66c91a2f21556e4578. --- specifyweb/backend/batch_identify/views.py | 34 ------------------- .../lib/components/BatchIdentify/index.tsx | 30 +--------------- .../js_src/lib/localization/batchIdentify.ts | 3 -- 3 files changed, 1 insertion(+), 66 deletions(-) diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py index 4a6e0f0bdfa..3688dd58d04 100644 --- a/specifyweb/backend/batch_identify/views.py +++ b/specifyweb/backend/batch_identify/views.py @@ -1,7 +1,6 @@ import json import re from collections.abc import Iterable -from collections import Counter from typing import Any, Literal from django import http @@ -340,29 +339,6 @@ def _extract_current_determination_ids(collection_objects: Iterable[Collectionob current_determination_ids.append(int(current_determination_match.group(1))) return current_determination_ids -def _filter_collection_objects_to_majority_type( - collection_objects: Iterable[Collectionobject], -) -> tuple[list[Collectionobject], list[str]]: - collection_objects_list = list(collection_objects) - if len(collection_objects_list) <= 1: - return collection_objects_list, [] - - type_counts = Counter( - collection_object.collectionobjecttype_id - for collection_object in collection_objects_list - ) - majority_type_id = max(type_counts, key=type_counts.get) - - retained_collection_objects: list[Collectionobject] = [] - differing_type_catalog_numbers: list[str] = [] - for collection_object in collection_objects_list: - if collection_object.collectionobjecttype_id == majority_type_id: - retained_collection_objects.append(collection_object) - elif isinstance(collection_object.catalognumber, str): - differing_type_catalog_numbers.append(collection_object.catalognumber) - - return retained_collection_objects, differing_type_catalog_numbers - def _parse_collection_object_ids(request_data: dict[str, Any]) -> list[int]: collection_object_ids = request_data.get('collectionObjectIds') if not isinstance(collection_object_ids, list): @@ -517,10 +493,6 @@ def _build_taxon_tree_groups( 'type': 'array', 'items': {'type': 'string'}, }, - 'differingTypeCatalogNumbers': { - 'type': 'array', - 'items': {'type': 'string'}, - }, 'hasMixedTaxonTrees': {'type': 'boolean'}, 'taxonTreeGroups': { 'type': 'array', @@ -567,7 +539,6 @@ def _build_taxon_tree_groups( 'collectionObjectIds', 'currentDeterminationIds', 'unmatchedCatalogNumbers', - 'differingTypeCatalogNumbers', 'hasMixedTaxonTrees', 'taxonTreeGroups', ], @@ -619,7 +590,6 @@ def batch_identify_resolve(request: http.HttpRequest): include_current_determinations=not validate_only, max_results=_MAX_RESOLVE_COLLECTION_OBJECTS, ) - collection_objects, differing_type_catalog_numbers = _filter_collection_objects_to_majority_type(collection_objects) has_mixed_taxon_trees = not _has_single_effective_collection_object_taxon_tree( collection_objects, request.specify_collection.discipline.taxontreedef_id, @@ -661,7 +631,6 @@ def batch_identify_resolve(request: http.HttpRequest): 'collectionObjectIds': collection_object_ids, 'currentDeterminationIds': current_determination_ids, 'unmatchedCatalogNumbers': unmatched_catalog_numbers, - 'differingTypeCatalogNumbers': differing_type_catalog_numbers, 'hasMixedTaxonTrees': has_mixed_taxon_trees, 'taxonTreeGroups': taxon_tree_groups, } @@ -792,9 +761,6 @@ def batch_identify(request: http.HttpRequest): status=400, ) - collection_objects, _ = _filter_collection_objects_to_majority_type(collection_objects) - collection_object_ids = [collection_object.id for collection_object in collection_objects] - if not _has_single_effective_collection_object_taxon_tree( collection_objects, request.specify_collection.discipline.taxontreedef_id, diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 5c3cb61c843..9125315c6f0 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -43,7 +43,6 @@ type BatchIdentifyResolveResponse = { readonly collectionObjectIds: RA; readonly currentDeterminationIds: RA; readonly unmatchedCatalogNumbers: RA; - readonly differingTypeCatalogNumbers: RA; readonly hasMixedTaxonTrees: boolean; readonly taxonTreeGroups: RA<{ readonly taxonTreeDefId: number | null; @@ -317,8 +316,6 @@ function BatchIdentifyDialog({ const [unmatchedCatalogNumbers, setUnmatchedCatalogNumbers] = React.useState< RA >([]); - const [differingTypeCatalogNumbers, setDifferingTypeCatalogNumbers] = - React.useState>([]); const [hasMixedTaxonTrees, setHasMixedTaxonTrees] = React.useState(false); const [taxonTreeGroups, setTaxonTreeGroups] = React.useState< BatchIdentifyResolveResponse['taxonTreeGroups'] @@ -564,7 +561,6 @@ function BatchIdentifyDialog({ setValidatedCatalogNumbersKey(entriesKey); setResolvedCollectionObjectIds(data.collectionObjectIds); setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); - setDifferingTypeCatalogNumbers(data.differingTypeCatalogNumbers); setHasMixedTaxonTrees(data.hasMixedTaxonTrees); setTaxonTreeGroups(data.taxonTreeGroups); }) @@ -620,7 +616,6 @@ function BatchIdentifyDialog({ if (catalogNumberRanges.length === 0) { setUnmatchedCatalogNumbers([]); - setDifferingTypeCatalogNumbers([]); setResolvedCollectionObjectIds([]); setHasMixedTaxonTrees(false); setTaxonTreeGroups([]); @@ -658,7 +653,6 @@ function BatchIdentifyDialog({ fetchRecordSetCollectionObjectIds(recordSet.id).then( (recordSetCollectionObjectIds) => { setUnmatchedCatalogNumbers([]); - setDifferingTypeCatalogNumbers([]); setResolvedCollectionObjectIds([]); setHasMixedTaxonTrees(false); setTaxonTreeGroups([]); @@ -676,7 +670,6 @@ function BatchIdentifyDialog({ if (validatedCatalogNumbersKey === catalogNumbersKey) { if (unmatchedCatalogNumbers.length > 0) return; - if (differingTypeCatalogNumbers.length > 0) return; if (hasMixedTaxonTrees) return; proceedWithCollectionObjects( resolvedCollectionObjectIds, @@ -691,11 +684,9 @@ function BatchIdentifyDialog({ setValidatedCatalogNumbersKey(catalogNumbersKey); setResolvedCollectionObjectIds(data.collectionObjectIds); setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); - setDifferingTypeCatalogNumbers(data.differingTypeCatalogNumbers); setHasMixedTaxonTrees(data.hasMixedTaxonTrees); setTaxonTreeGroups(data.taxonTreeGroups); if (data.hasMixedTaxonTrees) return; - if (data.differingTypeCatalogNumbers.length > 0) return; if (data.unmatchedCatalogNumbers.length > 0) { setCollectionObjectIds([]); setStep('catalogNumbers'); @@ -714,7 +705,6 @@ function BatchIdentifyDialog({ validatedCatalogNumbersKey, catalogNumbersKey, unmatchedCatalogNumbers, - differingTypeCatalogNumbers, hasMixedTaxonTrees, taxonTreeGroups, proceedWithCollectionObjects, @@ -886,8 +876,7 @@ function BatchIdentifyDialog({ disabled={ catalogNumberRanges.length === 0 || isResolving || - unmatchedCatalogNumbers.length > 0 || - differingTypeCatalogNumbers.length > 0 + unmatchedCatalogNumbers.length > 0 } onClick={handleNext} > @@ -935,7 +924,6 @@ function BatchIdentifyDialog({ onValueChange={(value): void => { setCatalogNumbers(value); setUnmatchedCatalogNumbers([]); - setDifferingTypeCatalogNumbers([]); setResolvedCollectionObjectIds([]); setHasMixedTaxonTrees(false); setTaxonTreeGroups([]); @@ -984,14 +972,6 @@ function BatchIdentifyDialog({ ))}
)} - {differingTypeCatalogNumbers.length > 0 && ( -
-

{batchIdentifyText.catalogNumbersDifferentType()}

- {differingTypeCatalogNumbers.map((catalogNumber, index) => ( -

{catalogNumber}

- ))} -
- )}
) : (
@@ -1003,14 +983,6 @@ function BatchIdentifyDialog({ ))}
)} - {differingTypeCatalogNumbers.length > 0 && ( -
-

{batchIdentifyText.catalogNumbersDifferentType()}

- {differingTypeCatalogNumbers.map((catalogNumber, index) => ( -

{catalogNumber}

- ))} -
- )}
diff --git a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts index 213ce4a427f..93971aa5ab2 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts @@ -17,9 +17,6 @@ export const batchIdentifyText = createDictionary({ catalogNumbersNotFound: { 'en-us': 'Catalog Numbers Not Found', }, - catalogNumbersDifferentType: { - 'en-us': 'Catalog Numbers Ignored Due To Different Collection Object Type', - }, identify: { 'en-us': 'Identify', }, From baddcb6320d8246e51986dfdf774a42cfe68ad56 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 3 Mar 2026 23:05:50 -0600 Subject: [PATCH 20/26] verify single tree record set --- specifyweb/backend/batch_identify/urls.py | 1 + specifyweb/backend/batch_identify/views.py | 171 ++++++++++++++++++ .../lib/components/BatchIdentify/index.tsx | 95 +++++++++- .../js_src/lib/localization/batchIdentify.ts | 11 ++ 4 files changed, 268 insertions(+), 10 deletions(-) diff --git a/specifyweb/backend/batch_identify/urls.py b/specifyweb/backend/batch_identify/urls.py index c6b101625fb..cb3d477f44c 100644 --- a/specifyweb/backend/batch_identify/urls.py +++ b/specifyweb/backend/batch_identify/urls.py @@ -4,5 +4,6 @@ urlpatterns = [ re_path(r'^batch_identify/resolve/$', views.batch_identify_resolve), + re_path(r'^batch_identify/validate_record_set/$', views.batch_identify_validate_record_set), re_path(r'^batch_identify/$', views.batch_identify), ] diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py index 3688dd58d04..719e0d05452 100644 --- a/specifyweb/backend/batch_identify/views.py +++ b/specifyweb/backend/batch_identify/views.py @@ -448,6 +448,177 @@ def _build_taxon_tree_groups( group['collectionObjectTypeNames'] = sorted(group['collectionObjectTypeNames']) return groups +@openapi( + schema={ + 'post': { + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + } + }, + 'required': ['collectionObjectIds'], + 'additionalProperties': False, + } + } + }, + }, + 'responses': { + '200': { + 'description': ( + 'Validated collection objects for Batch Identify and' + ' grouped them by effective taxon tree.' + ), + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'hasMixedTaxonTrees': {'type': 'boolean'}, + 'taxonTreeGroups': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'taxonTreeDefId': { + 'oneOf': [ + {'type': 'integer'}, + {'type': 'null'}, + ] + }, + 'taxonTreeName': { + 'oneOf': [ + {'type': 'string'}, + {'type': 'null'}, + ] + }, + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'catalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'collectionObjectTypeNames': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + 'required': [ + 'taxonTreeDefId', + 'taxonTreeName', + 'collectionObjectIds', + 'catalogNumbers', + 'collectionObjectTypeNames', + ], + 'additionalProperties': False, + }, + }, + }, + 'required': [ + 'collectionObjectIds', + 'hasMixedTaxonTrees', + 'taxonTreeGroups', + ], + 'additionalProperties': False, + } + } + }, + }, + '400': { + 'description': 'Invalid request payload.', + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': {'error': {'type': 'string'}}, + 'required': ['error'], + 'additionalProperties': False, + } + } + }, + }, + }, + } + } +) +@login_maybe_required +@require_POST +def batch_identify_validate_record_set(request: http.HttpRequest): + check_table_permissions( + request.specify_collection, request.specify_user, Collectionobject, 'read' + ) + + try: + request_data = json.loads(request.body) + except json.JSONDecodeError: + return http.JsonResponse({'error': 'Invalid JSON body.'}, status=400) + + try: + collection_object_ids = _parse_collection_object_ids(request_data) + except ValueError as error: + return http.JsonResponse({'error': str(error)}, status=400) + + collection_objects = list( + Collectionobject.objects.filter( + collectionmemberid=request.specify_collection.id, + id__in=collection_object_ids, + ) + .select_related('collectionobjecttype__taxontreedef') + .order_by('id') + ) + existing_collection_object_ids = { + collection_object.id for collection_object in collection_objects + } + missing_collection_object_ids = [ + collection_object_id + for collection_object_id in collection_object_ids + if collection_object_id not in existing_collection_object_ids + ] + if len(missing_collection_object_ids) > 0: + return http.JsonResponse( + { + 'error': ( + 'One or more collection object IDs do not exist or are not in' + ' the active collection.' + ) + }, + status=400, + ) + + has_mixed_taxon_trees = not _has_single_effective_collection_object_taxon_tree( + collection_objects, + request.specify_collection.discipline.taxontreedef_id, + ) + taxon_tree_groups = _build_taxon_tree_groups( + collection_objects, + request.specify_collection.discipline.taxontreedef_id, + ( + request.specify_collection.discipline.taxontreedef.name + if request.specify_collection.discipline.taxontreedef is not None + else None + ), + ) + + return http.JsonResponse( + { + 'collectionObjectIds': [collection_object.id for collection_object in collection_objects], + 'hasMixedTaxonTrees': has_mixed_taxon_trees, + 'taxonTreeGroups': taxon_tree_groups, + } + ) + @openapi( schema={ 'post': { diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 9125315c6f0..237e8ecc5dc 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -53,6 +53,11 @@ type BatchIdentifyResolveResponse = { }>; }; +type BatchIdentifyCollectionObjectValidationResponse = Pick< + BatchIdentifyResolveResponse, + 'collectionObjectIds' | 'hasMixedTaxonTrees' | 'taxonTreeGroups' +>; + type BatchIdentifySaveResponse = { readonly createdCount: number; readonly collectionObjectIds: RA; @@ -73,7 +78,7 @@ const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: readonly CatalogToken[] = []; + const tokens: CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -99,7 +104,7 @@ const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: readonly (readonly [number, number])[] = []; + const ranges: Array = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; @@ -221,7 +226,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: readonly number[] = []; + const collectionObjectIds: number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -320,6 +325,9 @@ function BatchIdentifyDialog({ const [taxonTreeGroups, setTaxonTreeGroups] = React.useState< BatchIdentifyResolveResponse['taxonTreeGroups'] >([]); + const [recordSetMixedTreeGroups, setRecordSetMixedTreeGroups] = React.useState< + BatchIdentifyResolveResponse['taxonTreeGroups'] + >([]); const [selectedTaxonTreeDefUri, setSelectedTaxonTreeDefUri] = React.useState(undefined); const [searchTreeCollectionObjectTypeIds, setSearchTreeCollectionObjectTypeIds] = @@ -517,6 +525,24 @@ function BatchIdentifyDialog({ [] ); + const validateCollectionObjects = React.useCallback( + async ( + collectionObjectIds: RA + ): Promise => + ajax( + '/api/specify/batch_identify/validate_record_set/', + { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + collectionObjectIds, + }, + errorMode: 'dismissible', + } + ).then(({ data }) => data), + [] + ); + const proceedWithCollectionObjects = React.useCallback( (resolvedIds: RA, taxonTreeDefId?: number | null): void => { setDetermination(createDetermination()); @@ -652,19 +678,41 @@ function BatchIdentifyDialog({ loading( fetchRecordSetCollectionObjectIds(recordSet.id).then( (recordSetCollectionObjectIds) => { - setUnmatchedCatalogNumbers([]); - setResolvedCollectionObjectIds([]); - setHasMixedTaxonTrees(false); - setTaxonTreeGroups([]); - setValidatedCatalogNumbersKey(''); - proceedWithCollectionObjects(recordSetCollectionObjectIds); + if (recordSetCollectionObjectIds.length === 0) { + setRecordSetMixedTreeGroups([]); + setIsRecordSetDialogOpen(true); + return; + } + return validateCollectionObjects(recordSetCollectionObjectIds).then( + (validationData) => { + if (validationData.hasMixedTaxonTrees) { + setRecordSetMixedTreeGroups(validationData.taxonTreeGroups); + return; + } + setRecordSetMixedTreeGroups([]); + setUnmatchedCatalogNumbers([]); + setResolvedCollectionObjectIds([]); + setHasMixedTaxonTrees(false); + setTaxonTreeGroups([]); + setValidatedCatalogNumbersKey(''); + proceedWithCollectionObjects( + validationData.collectionObjectIds, + validationData.taxonTreeGroups[0]?.taxonTreeDefId + ); + } + ); } ) ); }, - [loading, proceedWithCollectionObjects] + [loading, proceedWithCollectionObjects, validateCollectionObjects] ); + const handleCloseRecordSetTreeError = React.useCallback((): void => { + setRecordSetMixedTreeGroups([]); + setIsRecordSetDialogOpen(true); + }, []); + const handleNext = React.useCallback((): void => { if (catalogNumberRanges.length === 0 || isResolving) return; @@ -1031,6 +1079,33 @@ function BatchIdentifyDialog({ /> )} + {recordSetMixedTreeGroups.length > 0 && ( + + {commonText.close()} + + } + header={batchIdentifyText.invalidRecordSetTitle()} + onClose={handleCloseRecordSetTreeError} + > +
+

{batchIdentifyText.invalidRecordSetMessage()}

+
+ {recordSetMixedTreeGroups.map((group) => ( +

+ {commonText.colonLine({ + label: + group.taxonTreeName ?? batchIdentifyText.unknownTaxonTree(), + value: String(group.collectionObjectIds.length), + })} +

+ ))} +
+

{batchIdentifyText.invalidRecordSetInstructions()}

+
+
+ )} {searchDialog} {typeof isVerificationDialogOpen === 'number' && ( diff --git a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts index 93971aa5ab2..fa3c8e21656 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchIdentify.ts @@ -44,4 +44,15 @@ export const batchIdentifyText = createDictionary({ collectionObjectTypes: { 'en-us': 'Collection Object Types', }, + invalidRecordSetTitle: { + 'en-us': 'Invalid Record Set', + }, + invalidRecordSetMessage: { + 'en-us': + 'The selected record set contains collection objects from more than one taxon tree.', + }, + invalidRecordSetInstructions: { + 'en-us': + 'Choose a different record set, or edit the query used to create this record set so all collection objects use the same taxon tree.', + }, }); From 781e3c4a07e3226a8ade37fc293fc79d5162114b Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 3 Mar 2026 23:24:38 -0600 Subject: [PATCH 21/26] back-end code organizing --- .../backend/batch_identify/api_schemas.py | 234 +++++ .../backend/batch_identify/view_helpers.py | 463 ++++++++++ specifyweb/backend/batch_identify/views.py | 826 +----------------- 3 files changed, 743 insertions(+), 780 deletions(-) create mode 100644 specifyweb/backend/batch_identify/api_schemas.py create mode 100644 specifyweb/backend/batch_identify/view_helpers.py diff --git a/specifyweb/backend/batch_identify/api_schemas.py b/specifyweb/backend/batch_identify/api_schemas.py new file mode 100644 index 00000000000..704bb22292e --- /dev/null +++ b/specifyweb/backend/batch_identify/api_schemas.py @@ -0,0 +1,234 @@ +_TAXON_TREE_GROUP_ITEM_SCHEMA = { + 'type': 'object', + 'properties': { + 'taxonTreeDefId': { + 'oneOf': [ + {'type': 'integer'}, + {'type': 'null'}, + ] + }, + 'taxonTreeName': { + 'oneOf': [ + {'type': 'string'}, + {'type': 'null'}, + ] + }, + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'catalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'collectionObjectTypeNames': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + 'required': [ + 'taxonTreeDefId', + 'taxonTreeName', + 'collectionObjectIds', + 'catalogNumbers', + 'collectionObjectTypeNames', + ], + 'additionalProperties': False, +} + +_ERROR_RESPONSE_SCHEMA = { + 'description': 'Invalid request payload.', + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': {'error': {'type': 'string'}}, + 'required': ['error'], + 'additionalProperties': False, + } + } + }, +} + +BATCH_IDENTIFY_VALIDATE_RECORD_SET_OPENAPI_SCHEMA = { + 'post': { + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + } + }, + 'required': ['collectionObjectIds'], + 'additionalProperties': False, + } + } + }, + }, + 'responses': { + '200': { + 'description': ( + 'Validated collection objects for Batch Identify and' + ' grouped them by effective taxon tree.' + ), + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'hasMixedTaxonTrees': {'type': 'boolean'}, + 'taxonTreeGroups': { + 'type': 'array', + 'items': _TAXON_TREE_GROUP_ITEM_SCHEMA, + }, + }, + 'required': [ + 'collectionObjectIds', + 'hasMixedTaxonTrees', + 'taxonTreeGroups', + ], + 'additionalProperties': False, + } + } + }, + }, + '400': _ERROR_RESPONSE_SCHEMA, + }, + } +} + +BATCH_IDENTIFY_RESOLVE_OPENAPI_SCHEMA = { + 'post': { + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'catalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'validateOnly': {'type': 'boolean'}, + }, + 'required': ['catalogNumbers'], + 'additionalProperties': False, + } + } + }, + }, + 'responses': { + '200': { + 'description': ( + 'Resolved collection objects and validation details for' + ' catalog number input.' + ), + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'currentDeterminationIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'unmatchedCatalogNumbers': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'hasMixedTaxonTrees': {'type': 'boolean'}, + 'taxonTreeGroups': { + 'type': 'array', + 'items': _TAXON_TREE_GROUP_ITEM_SCHEMA, + }, + }, + 'required': [ + 'collectionObjectIds', + 'currentDeterminationIds', + 'unmatchedCatalogNumbers', + 'hasMixedTaxonTrees', + 'taxonTreeGroups', + ], + 'additionalProperties': False, + } + } + }, + }, + '400': _ERROR_RESPONSE_SCHEMA, + }, + } +} + +BATCH_IDENTIFY_OPENAPI_SCHEMA = { + 'post': { + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'determination': { + 'type': 'object', + 'additionalProperties': True, + }, + }, + 'required': ['collectionObjectIds', 'determination'], + 'additionalProperties': False, + } + } + }, + }, + 'responses': { + '200': { + 'description': ( + 'Created determination records for all selected collection' + ' objects.' + ), + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'createdCount': {'type': 'integer'}, + 'collectionObjectIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + 'determinationIds': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + }, + 'required': [ + 'createdCount', + 'collectionObjectIds', + 'determinationIds', + ], + 'additionalProperties': False, + } + } + }, + }, + '400': _ERROR_RESPONSE_SCHEMA, + }, + } +} diff --git a/specifyweb/backend/batch_identify/view_helpers.py b/specifyweb/backend/batch_identify/view_helpers.py new file mode 100644 index 00000000000..abe7150e82c --- /dev/null +++ b/specifyweb/backend/batch_identify/view_helpers.py @@ -0,0 +1,463 @@ +import re +from collections.abc import Iterable +from typing import Any, Literal + +from django.db.models import IntegerField, Prefetch, Q +from django.db.models.functions import Cast, Right, Substr + +from specifyweb.specify.api.calculated_fields import calculate_extra_fields +from specifyweb.specify.models import Collectionobject, Determination + +_RESOURCE_URI_ID_RE = re.compile(r'/(\d+)/?$') +_YEAR_CATALOG_NUMBER_DELIMITERS = '-/|._:; *$%#@' +_YEAR_CATALOG_NUMBER_DELIMITER_CLASS = re.escape(_YEAR_CATALOG_NUMBER_DELIMITERS) +_STORED_YEAR_CATALOG_NUMBER_RE = re.compile( + rf'^(?P\d{{4}})[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+(?P\d{{6}})$' +) +_ENTRY_YEAR_CATALOG_NUMBER_RE = re.compile( + rf'(?\d{{4}})[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+(?P\d{{6}})(?!\d)' +) +MAX_RESOLVE_COLLECTION_OBJECTS = 1000 +_NUMERIC_CATALOG_NUMBER_QUERY_REGEX = r'^[0-9]+$' +_YEAR_BASED_CATALOG_NUMBER_QUERY_REGEX = ( + rf'^[0-9]{{4}}[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+[0-9]{{6}}$' +) +_METADATA_KEYS = { + 'id', + 'resource_uri', + 'recordset_info', + '_tablename', + 'version', +} + +CatalogToken = int | Literal['-'] + +def _tokenize_catalog_entry(entry: str) -> list[CatalogToken]: + tokens: list[CatalogToken] = [] + current_number: list[str] = [] + + for character in entry: + if character.isdigit(): + current_number.append(character) + continue + + if len(current_number) > 0: + tokens.append(int(''.join(current_number))) + current_number = [] + + if character == '-': + tokens.append('-') + + if len(current_number) > 0: + tokens.append(int(''.join(current_number))) + + return tokens + +def parse_catalog_number_requests( + entries: Iterable[str], +) -> tuple[list[tuple[int, int]], dict[int, list[tuple[int, int]]]]: + numeric_ranges: list[tuple[int, int]] = [] + year_based_ranges: dict[int, list[tuple[int, int]]] = {} + + for raw_entry in entries: + entry = raw_entry.strip() + if entry == '': + continue + + year_matches = list(_ENTRY_YEAR_CATALOG_NUMBER_RE.finditer(entry)) + if len(year_matches) > 0: + index = 0 + while index < len(year_matches): + current_match = year_matches[index] + year = int(current_match.group('year')) + start = int(current_match.group('number')) + end = start + + if index + 1 < len(year_matches): + next_match = year_matches[index + 1] + if ( + int(next_match.group('year')) == year + and '-' in entry[current_match.end() : next_match.start()] + ): + end = int(next_match.group('number')) + index += 1 + + if start > end: + start, end = end, start + year_based_ranges.setdefault(year, []).append((start, end)) + index += 1 + + entry_segments: list[str] = [] + cursor = 0 + for match in year_matches: + entry_segments.append(entry[cursor : match.start()]) + entry_segments.append(' ' * (match.end() - match.start())) + cursor = match.end() + entry_segments.append(entry[cursor:]) + + numeric_entry = ''.join(entry_segments) + tokens = _tokenize_catalog_entry(numeric_entry) + if not any(isinstance(token, int) for token in tokens): + continue + + index = 0 + while index < len(tokens): + token = tokens[index] + if token == '-': + index += 1 + continue + + start = token + end = start + if ( + index + 2 < len(tokens) + and tokens[index + 1] == '-' + and isinstance(tokens[index + 2], int) + ): + end = tokens[index + 2] + index += 3 + else: + index += 1 + + if start > end: + start, end = end, start + numeric_ranges.append((start, end)) + + if len(numeric_ranges) == 0 and len(year_based_ranges) == 0: + raise ValueError('Provide at least one catalog number.') + + return numeric_ranges, year_based_ranges + +def _build_catalog_query(ranges: Iterable[tuple[int, int]]) -> Q: + query = Q() + for start, end in ranges: + if start == end: + query |= Q( + catalognumber__regex=_NUMERIC_CATALOG_NUMBER_QUERY_REGEX, + catalog_number_int=start, + ) + else: + query |= Q( + catalognumber__regex=_NUMERIC_CATALOG_NUMBER_QUERY_REGEX, + catalog_number_int__gte=start, + catalog_number_int__lte=end, + ) + return query + +def _build_year_based_catalog_query(year_based_ranges: dict[int, list[tuple[int, int]]]) -> Q: + query = Q() + for year, ranges in year_based_ranges.items(): + sequence_query = Q() + for start, end in ranges: + if start == end: + sequence_query |= Q(catalog_sequence_int=start) + else: + sequence_query |= Q( + catalog_sequence_int__gte=start, catalog_sequence_int__lte=end + ) + query |= ( + Q( + catalognumber__regex=_YEAR_BASED_CATALOG_NUMBER_QUERY_REGEX, + catalog_year_int=year, + ) + & sequence_query + ) + return query + +def _build_catalog_number_query( + numeric_ranges: list[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], +) -> Q: + query = Q() + if len(numeric_ranges) > 0: + query |= _build_catalog_query(numeric_ranges) + if len(year_based_ranges) > 0: + query |= _build_year_based_catalog_query(year_based_ranges) + return query + +def find_unmatched_catalog_numbers( + numeric_ranges: Iterable[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], + matched_catalog_numbers: set[int], + matched_year_based_catalog_numbers: set[tuple[int, int]], +) -> list[str]: + requested_numbers: set[int] = set() + for start, end in numeric_ranges: + requested_numbers.update(range(start, end + 1)) + + requested_year_based_numbers: set[tuple[int, int]] = set() + for year, ranges in year_based_ranges.items(): + for start, end in ranges: + requested_year_based_numbers.update( + (year, number) for number in range(start, end + 1) + ) + + unmatched_numeric_numbers = sorted( + requested_numbers - matched_catalog_numbers, + key=int, + ) + unmatched_year_based_numbers = sorted( + requested_year_based_numbers - matched_year_based_catalog_numbers + ) + + return [ + *(str(number) for number in unmatched_numeric_numbers), + *( + f'{year:04d}-{number:06d}' + for year, number in unmatched_year_based_numbers + ), + ] + +def sanitize_determination_payload(payload: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in payload.items() + if key.lower() not in _METADATA_KEYS and key.lower() != 'collectionobject' + } + +def parse_catalog_numbers(request_data: dict[str, Any]) -> list[str]: + catalog_numbers = request_data.get('catalogNumbers') + if not isinstance(catalog_numbers, list): + raise ValueError("'catalogNumbers' must be a list of strings.") + if not all(isinstance(entry, str) for entry in catalog_numbers): + raise ValueError("'catalogNumbers' must be a list of strings.") + return catalog_numbers + +def parse_validate_only(request_data: dict[str, Any]) -> bool: + validate_only = request_data.get('validateOnly', False) + if not isinstance(validate_only, bool): + raise ValueError("'validateOnly' must be a boolean.") + return validate_only + +def fetch_collection_objects_by_catalog_requests( + collection_id: int, + numeric_ranges: list[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], + include_current_determinations: bool = True, + max_results: int | None = None, +) -> list[Collectionobject]: + queryset = ( + Collectionobject.objects.filter(collectionmemberid=collection_id) + .exclude(catalognumber__isnull=True) + .exclude(catalognumber='') + .select_related('collectionobjecttype__taxontreedef') + ) + if len(numeric_ranges) > 0: + queryset = queryset.annotate(catalog_number_int=Cast('catalognumber', IntegerField())) + if len(year_based_ranges) > 0: + queryset = queryset.annotate( + catalog_year_int=Cast(Substr('catalognumber', 1, 4), IntegerField()), + catalog_sequence_int=Cast(Right('catalognumber', 6), IntegerField()), + ) + queryset = queryset.filter( + _build_catalog_number_query(numeric_ranges, year_based_ranges) + ).order_by('catalognumber', 'id') + if include_current_determinations: + queryset = queryset.prefetch_related( + Prefetch( + 'determinations', + queryset=Determination.objects.only('id', 'iscurrent'), + to_attr='prefetched_determinations', + ) + ) + if isinstance(max_results, int): + queryset = queryset[:max_results] + return list(queryset) + +def fetch_collection_objects_by_ids( + collection_id: int, + collection_object_ids: list[int], + order_by_id: bool = False, +) -> tuple[list[Collectionobject], list[int]]: + queryset = Collectionobject.objects.filter( + collectionmemberid=collection_id, + id__in=collection_object_ids, + ).select_related('collectionobjecttype__taxontreedef') + if order_by_id: + queryset = queryset.order_by('id') + + collection_objects = list(queryset) + existing_collection_object_ids = { + collection_object.id for collection_object in collection_objects + } + missing_collection_object_ids = [ + collection_object_id + for collection_object_id in collection_object_ids + if collection_object_id not in existing_collection_object_ids + ] + return collection_objects, missing_collection_object_ids + +def fetch_matched_catalog_number_identifiers( + collection_id: int, + numeric_ranges: list[tuple[int, int]], + year_based_ranges: dict[int, list[tuple[int, int]]], +) -> tuple[set[int], set[tuple[int, int]]]: + queryset = ( + Collectionobject.objects.filter( + collectionmemberid=collection_id + ) + .exclude(catalognumber__isnull=True) + .exclude(catalognumber='') + ) + if len(numeric_ranges) > 0: + queryset = queryset.annotate(catalog_number_int=Cast('catalognumber', IntegerField())) + if len(year_based_ranges) > 0: + queryset = queryset.annotate( + catalog_year_int=Cast(Substr('catalognumber', 1, 4), IntegerField()), + catalog_sequence_int=Cast(Right('catalognumber', 6), IntegerField()), + ) + catalog_numbers = queryset.filter( + _build_catalog_number_query(numeric_ranges, year_based_ranges) + ).values_list('catalognumber', flat=True) + + matched_numeric_numbers: set[int] = set() + matched_year_based_numbers: set[tuple[int, int]] = set() + for catalog_number in catalog_numbers: + if not isinstance(catalog_number, str): + continue + if catalog_number.isdigit(): + matched_numeric_numbers.add(int(catalog_number)) + year_match = _STORED_YEAR_CATALOG_NUMBER_RE.match(catalog_number) + if year_match is None: + continue + matched_year_based_numbers.add( + (int(year_match.group('year')), int(year_match.group('number'))) + ) + + return matched_numeric_numbers, matched_year_based_numbers + +def extract_current_determination_ids(collection_objects: Iterable[Collectionobject]) -> list[int]: + current_determination_ids: list[int] = [] + for collection_object in collection_objects: + determinations = getattr(collection_object, 'prefetched_determinations', []) + extra = calculate_extra_fields( + collection_object, + { + 'determinations': [ + { + 'resource_uri': ( + f"/api/specify/determination/{determination.id}/" + ), + 'iscurrent': determination.iscurrent, + } + for determination in determinations + ] + }, + ) + resource_uri = extra.get('currentdetermination') + if not isinstance(resource_uri, str): + continue + current_determination_match = _RESOURCE_URI_ID_RE.search(resource_uri) + if current_determination_match is None: + continue + current_determination_ids.append(int(current_determination_match.group(1))) + return current_determination_ids + +def parse_collection_object_ids(request_data: dict[str, Any]) -> list[int]: + collection_object_ids = request_data.get('collectionObjectIds') + if not isinstance(collection_object_ids, list): + raise ValueError("'collectionObjectIds' must be a list of numbers.") + if not all( + isinstance(collection_object_id, int) + for collection_object_id in collection_object_ids + ): + raise ValueError("'collectionObjectIds' must be a list of numbers.") + + deduplicated_ids: list[int] = [] + seen: set[int] = set() + for collection_object_id in collection_object_ids: + if collection_object_id <= 0 or collection_object_id in seen: + continue + seen.add(collection_object_id) + deduplicated_ids.append(collection_object_id) + + if len(deduplicated_ids) == 0: + raise ValueError('Provide at least one collection object ID.') + + return deduplicated_ids + +def _resolve_collection_object_taxon_tree_def_id( + collection_object: Collectionobject, + fallback_taxon_tree_def_id: int | None, +) -> int | None: + return ( + collection_object.collectionobjecttype.taxontreedef_id + if collection_object.collectionobjecttype is not None + else fallback_taxon_tree_def_id + ) + +def _resolve_collection_object_taxon_tree_name( + collection_object: Collectionobject, + fallback_taxon_tree_name: str | None, +) -> str | None: + return ( + collection_object.collectionobjecttype.taxontreedef.name + if collection_object.collectionobjecttype is not None + else fallback_taxon_tree_name + ) + +def has_single_effective_collection_object_taxon_tree( + collection_objects: Iterable[Collectionobject], + fallback_taxon_tree_def_id: int | None, +) -> bool: + effective_taxon_tree_def_ids = { + _resolve_collection_object_taxon_tree_def_id( + collection_object, + fallback_taxon_tree_def_id, + ) + for collection_object in collection_objects + } + if len(effective_taxon_tree_def_ids) == 0: + return True + if None in effective_taxon_tree_def_ids: + return False + return len(effective_taxon_tree_def_ids) == 1 + +def build_taxon_tree_groups( + collection_objects: Iterable[Collectionobject], + fallback_taxon_tree_def_id: int | None, + fallback_taxon_tree_name: str | None, +) -> list[dict[str, Any]]: + grouped: dict[int | None, dict[str, Any]] = {} + for collection_object in collection_objects: + tree_def_id = _resolve_collection_object_taxon_tree_def_id( + collection_object, + fallback_taxon_tree_def_id, + ) + tree_name = _resolve_collection_object_taxon_tree_name( + collection_object, + fallback_taxon_tree_name, + ) + group = grouped.setdefault( + tree_def_id, + { + 'taxonTreeDefId': tree_def_id, + 'taxonTreeName': tree_name, + 'collectionObjectIds': [], + 'catalogNumbers': [], + 'collectionObjectTypeNames': set(), + }, + ) + group['collectionObjectIds'].append(collection_object.id) + if isinstance(collection_object.catalognumber, str): + group['catalogNumbers'].append(collection_object.catalognumber) + + collection_object_type_name = ( + collection_object.collectionobjecttype.name + if collection_object.collectionobjecttype is not None + else None + ) + if isinstance(collection_object_type_name, str): + group['collectionObjectTypeNames'].add(collection_object_type_name) + else: + group['collectionObjectTypeNames'].add('Default Collection Object Type') + + groups = sorted( + grouped.values(), + key=lambda group: (group['taxonTreeName'] is None, group['taxonTreeName'] or ''), + ) + for group in groups: + group['collectionObjectIds'] = sorted(group['collectionObjectIds']) + group['catalogNumbers'] = sorted(group['catalogNumbers']) + group['collectionObjectTypeNames'] = sorted(group['collectionObjectTypeNames']) + return groups diff --git a/specifyweb/backend/batch_identify/views.py b/specifyweb/backend/batch_identify/views.py index 719e0d05452..b4eda0d380d 100644 --- a/specifyweb/backend/batch_identify/views.py +++ b/specifyweb/backend/batch_identify/views.py @@ -1,558 +1,36 @@ import json -import re -from collections.abc import Iterable -from typing import Any, Literal from django import http from django.db import transaction -from django.db.models import IntegerField, Prefetch, Q -from django.db.models.functions import Cast, Right, Substr from django.views.decorators.http import require_POST from specifyweb.backend.permissions.permissions import check_table_permissions -from specifyweb.specify.api.calculated_fields import calculate_extra_fields from specifyweb.specify.api.crud import post_resource from specifyweb.specify.models import Collectionobject, Determination from specifyweb.specify.views import login_maybe_required, openapi -_RESOURCE_URI_ID_RE = re.compile(r'/(\d+)/?$') -_YEAR_CATALOG_NUMBER_DELIMITERS = '-/|._:; *$%#@' -_YEAR_CATALOG_NUMBER_DELIMITER_CLASS = re.escape(_YEAR_CATALOG_NUMBER_DELIMITERS) -_STORED_YEAR_CATALOG_NUMBER_RE = re.compile( - rf'^(?P\d{{4}})[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+(?P\d{{6}})$' +from .view_helpers import ( + MAX_RESOLVE_COLLECTION_OBJECTS, + build_taxon_tree_groups, + extract_current_determination_ids, + fetch_collection_objects_by_catalog_requests, + fetch_collection_objects_by_ids, + fetch_matched_catalog_number_identifiers, + find_unmatched_catalog_numbers, + has_single_effective_collection_object_taxon_tree, + parse_catalog_number_requests, + parse_catalog_numbers, + parse_collection_object_ids, + parse_validate_only, + sanitize_determination_payload, ) -_ENTRY_YEAR_CATALOG_NUMBER_RE = re.compile( - rf'(?\d{{4}})[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+(?P\d{{6}})(?!\d)' +from .api_schemas import ( + BATCH_IDENTIFY_OPENAPI_SCHEMA, + BATCH_IDENTIFY_RESOLVE_OPENAPI_SCHEMA, + BATCH_IDENTIFY_VALIDATE_RECORD_SET_OPENAPI_SCHEMA, ) -_MAX_RESOLVE_COLLECTION_OBJECTS = 1000 -_NUMERIC_CATALOG_NUMBER_QUERY_REGEX = r'^[0-9]+$' -_YEAR_BASED_CATALOG_NUMBER_QUERY_REGEX = ( - rf'^[0-9]{{4}}[{_YEAR_CATALOG_NUMBER_DELIMITER_CLASS}]+[0-9]{{6}}$' -) -_METADATA_KEYS = { - 'id', - 'resource_uri', - 'recordset_info', - '_tablename', - 'version', -} - -CatalogToken = int | Literal['-'] - -def _tokenize_catalog_entry(entry: str) -> list[CatalogToken]: - tokens: list[CatalogToken] = [] - current_number: list[str] = [] - - for character in entry: - if character.isdigit(): - current_number.append(character) - continue - - if len(current_number) > 0: - tokens.append(int(''.join(current_number))) - current_number = [] - - if character == '-': - tokens.append('-') - - if len(current_number) > 0: - tokens.append(int(''.join(current_number))) - - return tokens - -def _parse_catalog_number_requests( - entries: Iterable[str], -) -> tuple[list[tuple[int, int]], dict[int, list[tuple[int, int]]]]: - numeric_ranges: list[tuple[int, int]] = [] - year_based_ranges: dict[int, list[tuple[int, int]]] = {} - - for raw_entry in entries: - entry = raw_entry.strip() - if entry == '': - continue - - year_matches = list(_ENTRY_YEAR_CATALOG_NUMBER_RE.finditer(entry)) - if len(year_matches) > 0: - index = 0 - while index < len(year_matches): - current_match = year_matches[index] - year = int(current_match.group('year')) - start = int(current_match.group('number')) - end = start - - if index + 1 < len(year_matches): - next_match = year_matches[index + 1] - if ( - int(next_match.group('year')) == year - and '-' in entry[current_match.end() : next_match.start()] - ): - end = int(next_match.group('number')) - index += 1 - - if start > end: - start, end = end, start - year_based_ranges.setdefault(year, []).append((start, end)) - index += 1 - - entry_segments: list[str] = [] - cursor = 0 - for match in year_matches: - entry_segments.append(entry[cursor : match.start()]) - entry_segments.append(' ' * (match.end() - match.start())) - cursor = match.end() - entry_segments.append(entry[cursor:]) - - numeric_entry = ''.join(entry_segments) - tokens = _tokenize_catalog_entry(numeric_entry) - if not any(isinstance(token, int) for token in tokens): - continue - - index = 0 - while index < len(tokens): - token = tokens[index] - if token == '-': - index += 1 - continue - - start = token - end = start - if ( - index + 2 < len(tokens) - and tokens[index + 1] == '-' - and isinstance(tokens[index + 2], int) - ): - end = tokens[index + 2] - index += 3 - else: - index += 1 - - if start > end: - start, end = end, start - numeric_ranges.append((start, end)) - - if len(numeric_ranges) == 0 and len(year_based_ranges) == 0: - raise ValueError('Provide at least one catalog number.') - - return numeric_ranges, year_based_ranges - -def _build_catalog_query(ranges: Iterable[tuple[int, int]]) -> Q: - query = Q() - for start, end in ranges: - if start == end: - query |= Q( - catalognumber__regex=_NUMERIC_CATALOG_NUMBER_QUERY_REGEX, - catalog_number_int=start, - ) - else: - query |= Q( - catalognumber__regex=_NUMERIC_CATALOG_NUMBER_QUERY_REGEX, - catalog_number_int__gte=start, - catalog_number_int__lte=end, - ) - return query - -def _build_year_based_catalog_query( - year_based_ranges: dict[int, list[tuple[int, int]]] -) -> Q: - query = Q() - for year, ranges in year_based_ranges.items(): - sequence_query = Q() - for start, end in ranges: - if start == end: - sequence_query |= Q(catalog_sequence_int=start) - else: - sequence_query |= Q( - catalog_sequence_int__gte=start, catalog_sequence_int__lte=end - ) - query |= ( - Q( - catalognumber__regex=_YEAR_BASED_CATALOG_NUMBER_QUERY_REGEX, - catalog_year_int=year, - ) - & sequence_query - ) - return query - -def _build_catalog_number_query( - numeric_ranges: list[tuple[int, int]], - year_based_ranges: dict[int, list[tuple[int, int]]], -) -> Q: - query = Q() - if len(numeric_ranges) > 0: - query |= _build_catalog_query(numeric_ranges) - if len(year_based_ranges) > 0: - query |= _build_year_based_catalog_query(year_based_ranges) - return query - -def _find_unmatched_catalog_numbers( - numeric_ranges: Iterable[tuple[int, int]], - year_based_ranges: dict[int, list[tuple[int, int]]], - matched_catalog_numbers: set[int], - matched_year_based_catalog_numbers: set[tuple[int, int]], -) -> list[str]: - requested_numbers: set[int] = set() - for start, end in numeric_ranges: - requested_numbers.update(range(start, end + 1)) - - requested_year_based_numbers: set[tuple[int, int]] = set() - for year, ranges in year_based_ranges.items(): - for start, end in ranges: - requested_year_based_numbers.update( - (year, number) for number in range(start, end + 1) - ) - - unmatched_numeric_numbers = sorted( - requested_numbers - matched_catalog_numbers, - key=int, - ) - unmatched_year_based_numbers = sorted( - requested_year_based_numbers - matched_year_based_catalog_numbers - ) - - return [ - *(str(number) for number in unmatched_numeric_numbers), - *( - f'{year:04d}-{number:06d}' - for year, number in unmatched_year_based_numbers - ), - ] - -def _sanitize_determination_payload(payload: dict[str, Any]) -> dict[str, Any]: - return { - key: value - for key, value in payload.items() - if key.lower() not in _METADATA_KEYS and key.lower() != 'collectionobject' - } - -def _parse_catalog_numbers(request_data: dict[str, Any]) -> list[str]: - catalog_numbers = request_data.get('catalogNumbers') - if not isinstance(catalog_numbers, list): - raise ValueError("'catalogNumbers' must be a list of strings.") - if not all(isinstance(entry, str) for entry in catalog_numbers): - raise ValueError("'catalogNumbers' must be a list of strings.") - return catalog_numbers - -def _parse_validate_only(request_data: dict[str, Any]) -> bool: - validate_only = request_data.get('validateOnly', False) - if not isinstance(validate_only, bool): - raise ValueError("'validateOnly' must be a boolean.") - return validate_only - -def _fetch_collection_objects_by_catalog_requests( - collection_id: int, - numeric_ranges: list[tuple[int, int]], - year_based_ranges: dict[int, list[tuple[int, int]]], - include_current_determinations: bool = True, - max_results: int | None = None, -) -> list[Collectionobject]: - queryset = ( - Collectionobject.objects.filter(collectionmemberid=collection_id) - .exclude(catalognumber__isnull=True) - .exclude(catalognumber='') - .select_related('collectionobjecttype__taxontreedef') - ) - if len(numeric_ranges) > 0: - queryset = queryset.annotate(catalog_number_int=Cast('catalognumber', IntegerField())) - if len(year_based_ranges) > 0: - queryset = queryset.annotate( - catalog_year_int=Cast(Substr('catalognumber', 1, 4), IntegerField()), - catalog_sequence_int=Cast(Right('catalognumber', 6), IntegerField()), - ) - queryset = queryset.filter( - _build_catalog_number_query(numeric_ranges, year_based_ranges) - ).order_by('catalognumber', 'id') - if include_current_determinations: - queryset = queryset.prefetch_related( - Prefetch( - 'determinations', - queryset=Determination.objects.only('id', 'iscurrent'), - to_attr='prefetched_determinations', - ) - ) - if isinstance(max_results, int): - queryset = queryset[:max_results] - return list(queryset) - -def _fetch_matched_catalog_number_identifiers( - collection_id: int, - numeric_ranges: list[tuple[int, int]], - year_based_ranges: dict[int, list[tuple[int, int]]], -) -> tuple[set[int], set[tuple[int, int]]]: - queryset = ( - Collectionobject.objects.filter( - collectionmemberid=collection_id - ) - .exclude(catalognumber__isnull=True) - .exclude(catalognumber='') - ) - if len(numeric_ranges) > 0: - queryset = queryset.annotate(catalog_number_int=Cast('catalognumber', IntegerField())) - if len(year_based_ranges) > 0: - queryset = queryset.annotate( - catalog_year_int=Cast(Substr('catalognumber', 1, 4), IntegerField()), - catalog_sequence_int=Cast(Right('catalognumber', 6), IntegerField()), - ) - catalog_numbers = queryset.filter( - _build_catalog_number_query(numeric_ranges, year_based_ranges) - ).values_list('catalognumber', flat=True) - - matched_numeric_numbers: set[int] = set() - matched_year_based_numbers: set[tuple[int, int]] = set() - for catalog_number in catalog_numbers: - if not isinstance(catalog_number, str): - continue - if catalog_number.isdigit(): - matched_numeric_numbers.add(int(catalog_number)) - year_match = _STORED_YEAR_CATALOG_NUMBER_RE.match(catalog_number) - if year_match is None: - continue - matched_year_based_numbers.add( - (int(year_match.group('year')), int(year_match.group('number'))) - ) - return matched_numeric_numbers, matched_year_based_numbers - -def _extract_current_determination_ids(collection_objects: Iterable[Collectionobject]) -> list[int]: - current_determination_ids: list[int] = [] - for collection_object in collection_objects: - determinations = getattr(collection_object, 'prefetched_determinations', []) - extra = calculate_extra_fields( - collection_object, - { - 'determinations': [ - { - 'resource_uri': ( - f"/api/specify/determination/{determination.id}/" - ), - 'iscurrent': determination.iscurrent, - } - for determination in determinations - ] - }, - ) - resource_uri = extra.get('currentdetermination') - if not isinstance(resource_uri, str): - continue - current_determination_match = _RESOURCE_URI_ID_RE.search(resource_uri) - if current_determination_match is None: - continue - current_determination_ids.append(int(current_determination_match.group(1))) - return current_determination_ids - -def _parse_collection_object_ids(request_data: dict[str, Any]) -> list[int]: - collection_object_ids = request_data.get('collectionObjectIds') - if not isinstance(collection_object_ids, list): - raise ValueError("'collectionObjectIds' must be a list of numbers.") - if not all( - isinstance(collection_object_id, int) - for collection_object_id in collection_object_ids - ): - raise ValueError("'collectionObjectIds' must be a list of numbers.") - - deduplicated_ids: list[int] = [] - seen: set[int] = set() - for collection_object_id in collection_object_ids: - if collection_object_id <= 0 or collection_object_id in seen: - continue - seen.add(collection_object_id) - deduplicated_ids.append(collection_object_id) - - if len(deduplicated_ids) == 0: - raise ValueError('Provide at least one collection object ID.') - - return deduplicated_ids - -def _resolve_collection_object_taxon_tree_def_id( - collection_object: Collectionobject, - fallback_taxon_tree_def_id: int | None, -) -> int | None: - return ( - collection_object.collectionobjecttype.taxontreedef_id - if collection_object.collectionobjecttype is not None - else fallback_taxon_tree_def_id - ) - -def _resolve_collection_object_taxon_tree_name( - collection_object: Collectionobject, - fallback_taxon_tree_name: str | None, -) -> str | None: - return ( - collection_object.collectionobjecttype.taxontreedef.name - if collection_object.collectionobjecttype is not None - else fallback_taxon_tree_name - ) - -def _has_single_effective_collection_object_taxon_tree( - collection_objects: Iterable[Collectionobject], - fallback_taxon_tree_def_id: int | None, -) -> bool: - effective_taxon_tree_def_ids = { - _resolve_collection_object_taxon_tree_def_id( - collection_object, - fallback_taxon_tree_def_id, - ) - for collection_object in collection_objects - } - if len(effective_taxon_tree_def_ids) == 0: - return True - if None in effective_taxon_tree_def_ids: - return False - return len(effective_taxon_tree_def_ids) == 1 - -def _build_taxon_tree_groups( - collection_objects: Iterable[Collectionobject], - fallback_taxon_tree_def_id: int | None, - fallback_taxon_tree_name: str | None, -) -> list[dict[str, Any]]: - grouped: dict[int | None, dict[str, Any]] = {} - for collection_object in collection_objects: - tree_def_id = _resolve_collection_object_taxon_tree_def_id( - collection_object, - fallback_taxon_tree_def_id, - ) - tree_name = _resolve_collection_object_taxon_tree_name( - collection_object, - fallback_taxon_tree_name, - ) - group = grouped.setdefault( - tree_def_id, - { - 'taxonTreeDefId': tree_def_id, - 'taxonTreeName': tree_name, - 'collectionObjectIds': [], - 'catalogNumbers': [], - 'collectionObjectTypeNames': set(), - }, - ) - group['collectionObjectIds'].append(collection_object.id) - if isinstance(collection_object.catalognumber, str): - group['catalogNumbers'].append(collection_object.catalognumber) - - collection_object_type_name = ( - collection_object.collectionobjecttype.name - if collection_object.collectionobjecttype is not None - else None - ) - if isinstance(collection_object_type_name, str): - group['collectionObjectTypeNames'].add(collection_object_type_name) - else: - group['collectionObjectTypeNames'].add('Default Collection Object Type') - - groups = sorted( - grouped.values(), - key=lambda group: (group['taxonTreeName'] is None, group['taxonTreeName'] or ''), - ) - for group in groups: - group['collectionObjectIds'] = sorted(group['collectionObjectIds']) - group['catalogNumbers'] = sorted(group['catalogNumbers']) - group['collectionObjectTypeNames'] = sorted(group['collectionObjectTypeNames']) - return groups - -@openapi( - schema={ - 'post': { - 'requestBody': { - 'required': True, - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'collectionObjectIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - } - }, - 'required': ['collectionObjectIds'], - 'additionalProperties': False, - } - } - }, - }, - 'responses': { - '200': { - 'description': ( - 'Validated collection objects for Batch Identify and' - ' grouped them by effective taxon tree.' - ), - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'collectionObjectIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - 'hasMixedTaxonTrees': {'type': 'boolean'}, - 'taxonTreeGroups': { - 'type': 'array', - 'items': { - 'type': 'object', - 'properties': { - 'taxonTreeDefId': { - 'oneOf': [ - {'type': 'integer'}, - {'type': 'null'}, - ] - }, - 'taxonTreeName': { - 'oneOf': [ - {'type': 'string'}, - {'type': 'null'}, - ] - }, - 'collectionObjectIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - 'catalogNumbers': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'collectionObjectTypeNames': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - }, - 'required': [ - 'taxonTreeDefId', - 'taxonTreeName', - 'collectionObjectIds', - 'catalogNumbers', - 'collectionObjectTypeNames', - ], - 'additionalProperties': False, - }, - }, - }, - 'required': [ - 'collectionObjectIds', - 'hasMixedTaxonTrees', - 'taxonTreeGroups', - ], - 'additionalProperties': False, - } - } - }, - }, - '400': { - 'description': 'Invalid request payload.', - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': {'error': {'type': 'string'}}, - 'required': ['error'], - 'additionalProperties': False, - } - } - }, - }, - }, - } - } -) +@openapi(schema=BATCH_IDENTIFY_VALIDATE_RECORD_SET_OPENAPI_SCHEMA) @login_maybe_required @require_POST def batch_identify_validate_record_set(request: http.HttpRequest): @@ -566,26 +44,15 @@ def batch_identify_validate_record_set(request: http.HttpRequest): return http.JsonResponse({'error': 'Invalid JSON body.'}, status=400) try: - collection_object_ids = _parse_collection_object_ids(request_data) + collection_object_ids = parse_collection_object_ids(request_data) except ValueError as error: return http.JsonResponse({'error': str(error)}, status=400) - collection_objects = list( - Collectionobject.objects.filter( - collectionmemberid=request.specify_collection.id, - id__in=collection_object_ids, - ) - .select_related('collectionobjecttype__taxontreedef') - .order_by('id') + collection_objects, missing_collection_object_ids = fetch_collection_objects_by_ids( + request.specify_collection.id, + collection_object_ids, + order_by_id=True, ) - existing_collection_object_ids = { - collection_object.id for collection_object in collection_objects - } - missing_collection_object_ids = [ - collection_object_id - for collection_object_id in collection_object_ids - if collection_object_id not in existing_collection_object_ids - ] if len(missing_collection_object_ids) > 0: return http.JsonResponse( { @@ -597,11 +64,11 @@ def batch_identify_validate_record_set(request: http.HttpRequest): status=400, ) - has_mixed_taxon_trees = not _has_single_effective_collection_object_taxon_tree( + has_mixed_taxon_trees = not has_single_effective_collection_object_taxon_tree( collection_objects, request.specify_collection.discipline.taxontreedef_id, ) - taxon_tree_groups = _build_taxon_tree_groups( + taxon_tree_groups = build_taxon_tree_groups( collection_objects, request.specify_collection.discipline.taxontreedef_id, ( @@ -619,122 +86,7 @@ def batch_identify_validate_record_set(request: http.HttpRequest): } ) -@openapi( - schema={ - 'post': { - 'requestBody': { - 'required': True, - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'catalogNumbers': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'validateOnly': {'type': 'boolean'}, - }, - 'required': ['catalogNumbers'], - 'additionalProperties': False, - } - } - }, - }, - 'responses': { - '200': { - 'description': ( - 'Resolved collection objects and validation details for' - ' catalog number input.' - ), - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'collectionObjectIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - 'currentDeterminationIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - 'unmatchedCatalogNumbers': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'hasMixedTaxonTrees': {'type': 'boolean'}, - 'taxonTreeGroups': { - 'type': 'array', - 'items': { - 'type': 'object', - 'properties': { - 'taxonTreeDefId': { - 'oneOf': [ - {'type': 'integer'}, - {'type': 'null'}, - ] - }, - 'taxonTreeName': { - 'oneOf': [ - {'type': 'string'}, - {'type': 'null'}, - ] - }, - 'collectionObjectIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - 'catalogNumbers': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'collectionObjectTypeNames': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - }, - 'required': [ - 'taxonTreeDefId', - 'taxonTreeName', - 'collectionObjectIds', - 'catalogNumbers', - 'collectionObjectTypeNames', - ], - 'additionalProperties': False, - }, - }, - }, - 'required': [ - 'collectionObjectIds', - 'currentDeterminationIds', - 'unmatchedCatalogNumbers', - 'hasMixedTaxonTrees', - 'taxonTreeGroups', - ], - 'additionalProperties': False, - } - } - }, - }, - '400': { - 'description': 'Invalid request payload.', - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': {'error': {'type': 'string'}}, - 'required': ['error'], - 'additionalProperties': False, - } - } - }, - }, - }, - } - } -) +@openapi(schema=BATCH_IDENTIFY_RESOLVE_OPENAPI_SCHEMA) @login_maybe_required @require_POST def batch_identify_resolve(request: http.HttpRequest): @@ -748,24 +100,24 @@ def batch_identify_resolve(request: http.HttpRequest): return http.JsonResponse({'error': 'Invalid JSON body.'}, status=400) try: - catalog_numbers = _parse_catalog_numbers(request_data) - numeric_ranges, year_based_ranges = _parse_catalog_number_requests(catalog_numbers) - validate_only = _parse_validate_only(request_data) + catalog_numbers = parse_catalog_numbers(request_data) + numeric_ranges, year_based_ranges = parse_catalog_number_requests(catalog_numbers) + validate_only = parse_validate_only(request_data) except ValueError as error: return http.JsonResponse({'error': str(error)}, status=400) - collection_objects = _fetch_collection_objects_by_catalog_requests( + collection_objects = fetch_collection_objects_by_catalog_requests( request.specify_collection.id, numeric_ranges, year_based_ranges, include_current_determinations=not validate_only, - max_results=_MAX_RESOLVE_COLLECTION_OBJECTS, + max_results=MAX_RESOLVE_COLLECTION_OBJECTS, ) - has_mixed_taxon_trees = not _has_single_effective_collection_object_taxon_tree( + has_mixed_taxon_trees = not has_single_effective_collection_object_taxon_tree( collection_objects, request.specify_collection.discipline.taxontreedef_id, ) - taxon_tree_groups = _build_taxon_tree_groups( + taxon_tree_groups = build_taxon_tree_groups( collection_objects, request.specify_collection.discipline.taxontreedef_id, ( @@ -774,24 +126,20 @@ def batch_identify_resolve(request: http.HttpRequest): else None ), ) - ( - matched_catalog_numbers, - matched_year_based_catalog_numbers, - ) = _fetch_matched_catalog_number_identifiers( + + matched_catalog_numbers, matched_year_based_catalog_numbers = fetch_matched_catalog_number_identifiers( request.specify_collection.id, numeric_ranges, year_based_ranges, ) - collection_object_ids = [ - collection_object.id for collection_object in collection_objects - ] + collection_object_ids = [collection_object.id for collection_object in collection_objects] current_determination_ids = ( - _extract_current_determination_ids(collection_objects) + extract_current_determination_ids(collection_objects) if not validate_only else [] ) - unmatched_catalog_numbers = _find_unmatched_catalog_numbers( + unmatched_catalog_numbers = find_unmatched_catalog_numbers( numeric_ranges, year_based_ranges, matched_catalog_numbers, @@ -807,79 +155,7 @@ def batch_identify_resolve(request: http.HttpRequest): } ) -@openapi( - schema={ - 'post': { - 'requestBody': { - 'required': True, - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'collectionObjectIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - 'determination': { - 'type': 'object', - 'additionalProperties': True, - }, - }, - 'required': ['collectionObjectIds', 'determination'], - 'additionalProperties': False, - } - } - }, - }, - 'responses': { - '200': { - 'description': ( - 'Created determination records for all selected collection' - ' objects.' - ), - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'createdCount': {'type': 'integer'}, - 'collectionObjectIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - 'determinationIds': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - }, - 'required': [ - 'createdCount', - 'collectionObjectIds', - 'determinationIds', - ], - 'additionalProperties': False, - } - } - }, - }, - '400': { - 'description': 'Invalid request payload.', - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': {'error': {'type': 'string'}}, - 'required': ['error'], - 'additionalProperties': False, - } - } - }, - }, - }, - } - } -) +@openapi(schema=BATCH_IDENTIFY_OPENAPI_SCHEMA) @login_maybe_required @require_POST @transaction.atomic @@ -897,7 +173,7 @@ def batch_identify(request: http.HttpRequest): return http.JsonResponse({'error': 'Invalid JSON body.'}, status=400) try: - collection_object_ids = _parse_collection_object_ids(request_data) + collection_object_ids = parse_collection_object_ids(request_data) except ValueError as error: return http.JsonResponse({'error': str(error)}, status=400) @@ -907,20 +183,10 @@ def batch_identify(request: http.HttpRequest): {'error': "'determination' must be an object."}, status=400 ) - collection_objects = list( - Collectionobject.objects.filter( - collectionmemberid=request.specify_collection.id, - id__in=collection_object_ids, - ).select_related('collectionobjecttype__taxontreedef') + collection_objects, missing_collection_object_ids = fetch_collection_objects_by_ids( + request.specify_collection.id, + collection_object_ids, ) - existing_collection_object_ids = { - collection_object.id for collection_object in collection_objects - } - missing_collection_object_ids = [ - collection_object_id - for collection_object_id in collection_object_ids - if collection_object_id not in existing_collection_object_ids - ] if len(missing_collection_object_ids) > 0: return http.JsonResponse( { @@ -932,7 +198,7 @@ def batch_identify(request: http.HttpRequest): status=400, ) - if not _has_single_effective_collection_object_taxon_tree( + if not has_single_effective_collection_object_taxon_tree( collection_objects, request.specify_collection.discipline.taxontreedef_id, ): @@ -947,7 +213,7 @@ def batch_identify(request: http.HttpRequest): status=400, ) - cleaned_payload = _sanitize_determination_payload(determination_payload) + cleaned_payload = sanitize_determination_payload(determination_payload) mark_as_current = cleaned_payload.get('isCurrent') is True if mark_as_current: check_table_permissions( From a4514be313241a40ae405a77a4778e8a7ea5989c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 3 Mar 2026 23:39:52 -0600 Subject: [PATCH 22/26] initial unit tests --- specifyweb/backend/batch_identify/tests.py | 181 ++++++++++++++++++ .../__tests__/parseCatalogNumbers.test.ts | 47 +++++ .../lib/components/BatchIdentify/index.tsx | 65 +------ .../BatchIdentify/parseCatalogNumbers.ts | 59 ++++++ 4 files changed, 291 insertions(+), 61 deletions(-) create mode 100644 specifyweb/backend/batch_identify/tests.py create mode 100644 specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts create mode 100644 specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts diff --git a/specifyweb/backend/batch_identify/tests.py b/specifyweb/backend/batch_identify/tests.py new file mode 100644 index 00000000000..ac0901e40c0 --- /dev/null +++ b/specifyweb/backend/batch_identify/tests.py @@ -0,0 +1,181 @@ +import json + +from django.test import Client + +from specifyweb.specify.models import ( + Collectionobject, + Collectionobjecttype, + Determination, + Taxontreedef, +) +from specifyweb.specify.tests.test_api import ApiTests + +class TestBatchIdentify(ApiTests): + def setUp(self): + super().setUp() + self.c = Client() + self.c.force_login(self.specifyuser) + self.c.cookies['collection'] = str(self.collection.id) + + def _post_json(self, url: str, payload: dict): + return self.c.post(url, json.dumps(payload), content_type='application/json') + + def test_batch_identify_resolve_parses_numeric_and_year_catalog_numbers(self): + numeric_one = Collectionobject.objects.create( + collection=self.collection, + catalognumber='000271806', + collectionobjecttype=self.collectionobjecttype, + ) + numeric_two = Collectionobject.objects.create( + collection=self.collection, + catalognumber='000687972', + collectionobjecttype=self.collectionobjecttype, + ) + year_based = Collectionobject.objects.create( + collection=self.collection, + catalognumber='2025-000001', + collectionobjecttype=self.collectionobjecttype, + ) + + response = self._post_json( + '/api/specify/batch_identify/resolve/', + { + 'catalogNumbers': [ + 'SEMC000271806,000687972', + '2025-000001', + '2025-000002', + ], + 'validateOnly': True, + }, + ) + self._assertStatusCodeEqual(response, 200) + + data = json.loads(response.content.decode()) + self.assertEqual( + set(data['collectionObjectIds']), + {numeric_one.id, numeric_two.id, year_based.id}, + ) + self.assertEqual(data['currentDeterminationIds'], []) + self.assertEqual(data['unmatchedCatalogNumbers'], ['2025-000002']) + self.assertEqual(data['hasMixedTaxonTrees'], False) + self.assertEqual(len(data['taxonTreeGroups']), 1) + self.assertEqual( + data['taxonTreeGroups'][0]['collectionObjectIds'], + sorted([numeric_one.id, numeric_two.id, year_based.id]), + ) + + def test_validate_record_set_reports_counts_for_mixed_trees(self): + other_tree = Taxontreedef.objects.create(name='Other Taxon Tree') + other_type = Collectionobjecttype.objects.create( + name='Other CO Type', + collection=self.collection, + taxontreedef=other_tree, + ) + + first_tree_object = Collectionobject.objects.create( + collection=self.collection, + catalognumber='000000001', + collectionobjecttype=self.collectionobjecttype, + ) + second_tree_object = Collectionobject.objects.create( + collection=self.collection, + catalognumber='000000002', + collectionobjecttype=other_type, + ) + + response = self._post_json( + '/api/specify/batch_identify/validate_record_set/', + { + 'collectionObjectIds': [ + first_tree_object.id, + second_tree_object.id, + ] + }, + ) + self._assertStatusCodeEqual(response, 200) + + data = json.loads(response.content.decode()) + self.assertEqual(data['hasMixedTaxonTrees'], True) + self.assertEqual( + sorted(len(group['collectionObjectIds']) for group in data['taxonTreeGroups']), + [1, 1], + ) + self.assertEqual( + sorted(group['taxonTreeName'] for group in data['taxonTreeGroups']), + sorted([self.taxontreedef.name, other_tree.name]), + ) + + def test_validate_record_set_rejects_missing_collection_object_ids(self): + response = self._post_json( + '/api/specify/batch_identify/validate_record_set/', + {'collectionObjectIds': [999999]}, + ) + self._assertStatusCodeEqual(response, 400) + + data = json.loads(response.content.decode()) + self.assertIn('do not exist', data['error']) + + def test_batch_identify_keeps_existing_current_when_new_determination_not_current(self): + collection_object = Collectionobject.objects.create( + collection=self.collection, + catalognumber='000000003', + collectionobjecttype=self.collectionobjecttype, + ) + existing = Determination.objects.create( + collectionobject=collection_object, + collectionmemberid=self.collection.id, + iscurrent=True, + ) + + response = self._post_json( + '/api/specify/batch_identify/', + { + 'collectionObjectIds': [collection_object.id], + 'determination': {'isCurrent': False, 'remarks': 'batch identify'}, + }, + ) + self._assertStatusCodeEqual(response, 200) + + data = json.loads(response.content.decode()) + self.assertEqual(data['createdCount'], 1) + created = Determination.objects.get(id=data['determinationIds'][0]) + existing.refresh_from_db() + + self.assertEqual(existing.iscurrent, True) + self.assertEqual(created.iscurrent, False) + self.assertEqual(created.collectionobject_id, collection_object.id) + + def test_batch_identify_sets_new_determination_current_when_requested(self): + collection_object = Collectionobject.objects.create( + collection=self.collection, + catalognumber='000000004', + collectionobjecttype=self.collectionobjecttype, + ) + existing = Determination.objects.create( + collectionobject=collection_object, + collectionmemberid=self.collection.id, + iscurrent=True, + ) + + response = self._post_json( + '/api/specify/batch_identify/', + { + 'collectionObjectIds': [collection_object.id], + 'determination': {'isCurrent': True, 'remarks': 'new current'}, + }, + ) + self._assertStatusCodeEqual(response, 200) + + data = json.loads(response.content.decode()) + created = Determination.objects.get(id=data['determinationIds'][0]) + existing.refresh_from_db() + + self.assertEqual(existing.iscurrent, False) + self.assertEqual(created.iscurrent, True) + self.assertEqual( + Determination.objects.filter( + collectionobject=collection_object, + iscurrent=True, + ).count(), + 1, + ) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts new file mode 100644 index 00000000000..084636e1624 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts @@ -0,0 +1,47 @@ +import { + parseCatalogNumberEntries, + parseCatalogNumberRanges, + tokenizeCatalogEntry, +} from '../parseCatalogNumbers'; + +describe('parseCatalogNumberEntries', () => { + test('trims lines and removes empty lines', () => { + expect(parseCatalogNumberEntries('\n 0001 \n\n0002\n')).toEqual([ + '0001', + '0002', + ]); + }); +}); + +describe('tokenizeCatalogEntry', () => { + test('treats non-numeric characters as delimiters except dash', () => { + expect(tokenizeCatalogEntry('SEMC000271806,SEMC000687972;000601108')).toEqual( + [271806, 687972, 601108] + ); + }); + + test('retains dash as a range token', () => { + expect(tokenizeCatalogEntry('0001 - 0150')).toEqual([1, '-', 150]); + }); +}); + +describe('parseCatalogNumberRanges', () => { + test('parses single catalog numbers split by non-numeric delimiters', () => { + expect( + parseCatalogNumberRanges([ + 'SEMC000271806 SEMC000687972 SEMC000601108', + ]) + ).toEqual([ + [271806, 271806], + [687972, 687972], + [601108, 601108], + ]); + }); + + test('parses ranges and normalizes reversed ranges', () => { + expect(parseCatalogNumberRanges(['0001 - 0150', '0150-0001'])).toEqual([ + [1, 150], + [1, 150], + ]); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 237e8ecc5dc..26f03119436 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -38,6 +38,10 @@ import { TreeDefinitionContext } from '../QueryComboBox/useTreeData'; import { OverlayContext } from '../Router/Router'; import { useSearchDialog } from '../SearchDialog'; import { RecordSetsDialog } from '../Toolbar/RecordSets'; +import { + parseCatalogNumberEntries, + parseCatalogNumberRanges, +} from './parseCatalogNumbers'; type BatchIdentifyResolveResponse = { readonly collectionObjectIds: RA; @@ -66,70 +70,9 @@ type BatchIdentifySaveResponse = { type Step = 'catalogNumbers' | 'determination'; -type CatalogToken = number | '-'; - const liveValidationDebounceMs = 1000; const collectionObjectViewPathRe = /\/specify\/view\/collectionobject\/(\d+)\/?$/i; -const parseCatalogNumberEntries = (rawEntries: string): RA => - rawEntries - .split('\n') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - -const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; - let currentNumber = ''; - - for (const character of entry) { - if (character >= '0' && character <= '9') { - currentNumber += character; - continue; - } - - if (currentNumber.length > 0) { - tokens.push(Number(currentNumber)); - currentNumber = ''; - } - - if (character === '-') tokens.push('-'); - } - - if (currentNumber.length > 0) tokens.push(Number(currentNumber)); - return tokens; -}; - -const parseCatalogNumberRanges = ( - entries: RA -): RA => - entries.flatMap((entry) => { - const tokens = tokenizeCatalogEntry(entry); - const ranges: Array = []; - let index = 0; - while (index < tokens.length) { - const token = tokens[index]; - if (token === '-') { - index += 1; - continue; - } - - let start = token; - let end = start; - const rangeEndToken = tokens[index + 2]; - if ( - tokens[index + 1] === '-' && - typeof rangeEndToken === 'number' - ) { - end = rangeEndToken; - index += 3; - } else index += 1; - - if (start > end) [start, end] = [end, start]; - ranges.push([start, end]); - } - return ranges; - }); - const queryFilterDefaults = { isNot: false, isStrict: false, diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts new file mode 100644 index 00000000000..c8aac4503aa --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts @@ -0,0 +1,59 @@ +import type { RA } from '../../utils/types'; + +type CatalogToken = number | '-'; + +export const parseCatalogNumberEntries = (rawEntries: string): RA => + rawEntries + .split('\n') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + +export const tokenizeCatalogEntry = (entry: string): RA => { + const tokens: CatalogToken[] = []; + let currentNumber = ''; + + for (const character of entry) { + if (character >= '0' && character <= '9') { + currentNumber += character; + continue; + } + + if (currentNumber.length > 0) { + tokens.push(Number(currentNumber)); + currentNumber = ''; + } + + if (character === '-') tokens.push('-'); + } + + if (currentNumber.length > 0) tokens.push(Number(currentNumber)); + return tokens; +}; + +export const parseCatalogNumberRanges = ( + entries: RA +): RA => + entries.flatMap((entry) => { + const tokens = tokenizeCatalogEntry(entry); + const ranges: Array = []; + let index = 0; + while (index < tokens.length) { + const token = tokens[index]; + if (token === '-') { + index += 1; + continue; + } + + let start = token; + let end = start; + const rangeEndToken = tokens[index + 2]; + if (tokens[index + 1] === '-' && typeof rangeEndToken === 'number') { + end = rangeEndToken; + index += 3; + } else index += 1; + + if (start > end) [start, end] = [end, start]; + ranges.push([start, end]); + } + return ranges; + }); From 057afdbb8e4b4670a88f66f5a63acb99255b0be2 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 4 Mar 2026 05:43:57 +0000 Subject: [PATCH 23/26] Lint code with ESLint and Prettier Triggered by a4514be313241a40ae405a77a4778e8a7ea5989c on branch refs/heads/issue-7764 --- .../__tests__/parseCatalogNumbers.test.ts | 8 +- .../lib/components/BatchIdentify/index.tsx | 161 +++++++++--------- .../BatchIdentify/parseCatalogNumbers.ts | 4 +- 3 files changed, 89 insertions(+), 84 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts index 084636e1624..5412a23ee4a 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/__tests__/parseCatalogNumbers.test.ts @@ -16,7 +16,7 @@ describe('parseCatalogNumberEntries', () => { describe('tokenizeCatalogEntry', () => { test('treats non-numeric characters as delimiters except dash', () => { expect(tokenizeCatalogEntry('SEMC000271806,SEMC000687972;000601108')).toEqual( - [271806, 687972, 601108] + [271_806, 687_972, 601_108] ); }); @@ -32,9 +32,9 @@ describe('parseCatalogNumberRanges', () => { 'SEMC000271806 SEMC000687972 SEMC000601108', ]) ).toEqual([ - [271806, 271806], - [687972, 687972], - [601108, 601108], + [271_806, 271_806], + [687_972, 687_972], + [601_108, 601_108], ]); }); diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 26f03119436..e73121efaae 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -71,7 +71,8 @@ type BatchIdentifySaveResponse = { type Step = 'catalogNumbers' | 'determination'; const liveValidationDebounceMs = 1000; -const collectionObjectViewPathRe = /\/specify\/view\/collectionobject\/(\d+)\/?$/i; +const collectionObjectViewPathRe = + /\/specify\/view\/collectionobject\/(\d+)\/?$/i; const queryFilterDefaults = { isNot: false, @@ -169,7 +170,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: number[] = []; + const collectionObjectIds: readonly number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( @@ -242,12 +243,13 @@ function BatchIdentifyDialog({ const [isIdentifying, setIsIdentifying] = React.useState(false); const [isResolving, setIsResolving] = React.useState(false); const [isLiveValidating, setIsLiveValidating] = React.useState(false); - const [isRecordSetDialogOpen, setIsRecordSetDialogOpen] = React.useState(false); + const [isRecordSetDialogOpen, setIsRecordSetDialogOpen] = + React.useState(false); const [isVerificationDialogOpen, setIsVerificationDialogOpen] = React.useState(); - const [collectionObjectIds, setCollectionObjectIds] = React.useState>( - [] - ); + const [collectionObjectIds, setCollectionObjectIds] = React.useState< + RA + >([]); const [createdRecordSet, setCreatedRecordSet] = React.useState< SerializedResource | undefined >(undefined); @@ -268,13 +270,15 @@ function BatchIdentifyDialog({ const [taxonTreeGroups, setTaxonTreeGroups] = React.useState< BatchIdentifyResolveResponse['taxonTreeGroups'] >([]); - const [recordSetMixedTreeGroups, setRecordSetMixedTreeGroups] = React.useState< - BatchIdentifyResolveResponse['taxonTreeGroups'] - >([]); - const [selectedTaxonTreeDefUri, setSelectedTaxonTreeDefUri] = - React.useState(undefined); - const [searchTreeCollectionObjectTypeIds, setSearchTreeCollectionObjectTypeIds] = - React.useState | undefined>(undefined); + const [recordSetMixedTreeGroups, setRecordSetMixedTreeGroups] = + React.useState([]); + const [selectedTaxonTreeDefUri, setSelectedTaxonTreeDefUri] = React.useState< + string | undefined + >(undefined); + const [ + searchTreeCollectionObjectTypeIds, + setSearchTreeCollectionObjectTypeIds, + ] = React.useState | undefined>(undefined); const [previewRunCount, setPreviewRunCount] = React.useState(0); const [selectedPreviewRows, setSelectedPreviewRows] = React.useState< ReadonlySet @@ -364,7 +368,8 @@ function BatchIdentifyDialog({ ...collectionObjectIds, ...candidateIds, ]); - if (mergedCollectionObjectIds.length === collectionObjectIds.length) return; + if (mergedCollectionObjectIds.length === collectionObjectIds.length) + return; setCollectionObjectIds(mergedCollectionObjectIds); setSelectedPreviewRows(new Set()); setPreviewRunCount((count) => count + 1); @@ -432,7 +437,7 @@ function BatchIdentifyDialog({ if (!(target instanceof Element)) return; const link = target.closest( 'a.print\\:hidden[target="_blank"]' - ) ; + ); if (link === null) return; const match = collectionObjectViewPathRe.exec(link.href); if (match === null) return; @@ -456,15 +461,18 @@ function BatchIdentifyDialog({ readonly errorMode?: 'dismissible' | 'silent'; } = {} ): Promise => - ajax('/api/specify/batch_identify/resolve/', { - method: 'POST', - headers: { Accept: 'application/json' }, - body: { - catalogNumbers: entries, - validateOnly: options.validateOnly === true, - }, - errorMode: options.errorMode ?? 'dismissible', - }).then(({ data }) => data), + ajax( + '/api/specify/batch_identify/resolve/', + { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + catalogNumbers: entries, + validateOnly: options.validateOnly === true, + }, + errorMode: options.errorMode ?? 'dismissible', + } + ).then(({ data }) => data), [] ); @@ -592,12 +600,7 @@ function BatchIdentifyDialog({ return; } scheduleLiveValidation(false); - }, [ - step, - catalogNumberRanges, - scheduleLiveValidation, - catalogNumbersKey, - ]); + }, [step, catalogNumberRanges, scheduleLiveValidation, catalogNumbersKey]); React.useEffect(() => { if (step !== 'catalogNumbers') return; @@ -620,7 +623,7 @@ function BatchIdentifyDialog({ setIsRecordSetDialogOpen(false); loading( fetchRecordSetCollectionObjectIds(recordSet.id).then( - (recordSetCollectionObjectIds) => { + async (recordSetCollectionObjectIds) => { if (recordSetCollectionObjectIds.length === 0) { setRecordSetMixedTreeGroups([]); setIsRecordSetDialogOpen(true); @@ -671,23 +674,24 @@ function BatchIdentifyDialog({ setIsResolving(true); loading( - resolveCatalogNumbers(catalogNumberEntries).then((data) => { - setValidatedCatalogNumbersKey(catalogNumbersKey); - setResolvedCollectionObjectIds(data.collectionObjectIds); - setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); - setHasMixedTaxonTrees(data.hasMixedTaxonTrees); - setTaxonTreeGroups(data.taxonTreeGroups); - if (data.hasMixedTaxonTrees) return; - if (data.unmatchedCatalogNumbers.length > 0) { - setCollectionObjectIds([]); - setStep('catalogNumbers'); - return; - } - proceedWithCollectionObjects( - data.collectionObjectIds, - data.taxonTreeGroups[0]?.taxonTreeDefId - ); - }) + resolveCatalogNumbers(catalogNumberEntries) + .then((data) => { + setValidatedCatalogNumbersKey(catalogNumbersKey); + setResolvedCollectionObjectIds(data.collectionObjectIds); + setUnmatchedCatalogNumbers(data.unmatchedCatalogNumbers); + setHasMixedTaxonTrees(data.hasMixedTaxonTrees); + setTaxonTreeGroups(data.taxonTreeGroups); + if (data.hasMixedTaxonTrees) return; + if (data.unmatchedCatalogNumbers.length > 0) { + setCollectionObjectIds([]); + setStep('catalogNumbers'); + return; + } + proceedWithCollectionObjects( + data.collectionObjectIds, + data.taxonTreeGroups[0]?.taxonTreeDefId + ); + }) .finally(() => setIsResolving(false)) ); }, [ @@ -705,30 +709,27 @@ function BatchIdentifyDialog({ catalogNumberEntries, ]); - const handleIdentify = React.useCallback( - (): void => { - if (collectionObjectIds.length === 0 || isIdentifying) return; - setIsIdentifying(true); - loading( - ajax('/api/specify/batch_identify/', { - method: 'POST', - headers: { Accept: 'application/json' }, - body: { - collectionObjectIds, - determination: serializeResource(determination), - }, - errorMode: 'dismissible', + const handleIdentify = React.useCallback((): void => { + if (collectionObjectIds.length === 0 || isIdentifying) return; + setIsIdentifying(true); + loading( + ajax('/api/specify/batch_identify/', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + collectionObjectIds, + determination: serializeResource(determination), + }, + errorMode: 'dismissible', + }) + .then(({ data }) => { + setCreatedRecordSet(undefined); + setIdentifiedCollectionObjectIds(f.unique(data.collectionObjectIds)); + setShowSuccessDialog(true); }) - .then(({ data }) => { - setCreatedRecordSet(undefined); - setIdentifiedCollectionObjectIds(f.unique(data.collectionObjectIds)); - setShowSuccessDialog(true); - }) - .finally(() => setIsIdentifying(false)) - ); - }, - [collectionObjectIds, isIdentifying, loading, determination] - ); + .finally(() => setIsIdentifying(false)) + ); + }, [collectionObjectIds, isIdentifying, loading, determination]); const handleCreateRecordSetAfterIdentify = React.useCallback((): void => { if ( @@ -759,7 +760,7 @@ function BatchIdentifyDialog({ disabled={isIdentifying || isSearchTreeFilterLoading} onClick={showSearchDialog} /> - + {commonText.close()} - } + } className={{ container: dialogClassNames.narrowContainer, }} @@ -921,7 +922,9 @@ function BatchIdentifyDialog({ setValidatedCatalogNumbersKey(''); }} /> - {isLiveValidating &&

{batchIdentifyText.validatingCatalogNumbers()}

} + {isLiveValidating && ( +

{batchIdentifyText.validatingCatalogNumbers()}

+ )} {hasMixedTaxonTrees && (

{resourcesText.selectDeterminationTaxon()}

@@ -931,9 +934,10 @@ function BatchIdentifyDialog({ key={group.taxonTreeDefId ?? 'none'} >

- {`${group.taxonTreeName ?? - batchIdentifyText.unknownTaxonTree() - } (${group.collectionObjectIds.length})`} + {`${ + group.taxonTreeName ?? + batchIdentifyText.unknownTaxonTree() + } (${group.collectionObjectIds.length})`}

{commonText.colonLine({ @@ -1039,7 +1043,8 @@ function BatchIdentifyDialog({

{commonText.colonLine({ label: - group.taxonTreeName ?? batchIdentifyText.unknownTaxonTree(), + group.taxonTreeName ?? + batchIdentifyText.unknownTaxonTree(), value: String(group.collectionObjectIds.length), })}

diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts index c8aac4503aa..544a5b74833 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts @@ -9,7 +9,7 @@ export const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); export const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; + const tokens: readonly CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -35,7 +35,7 @@ export const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: Array = []; + const ranges: readonly (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; From 46046aa346fc5c3c0ca258a12ac27c86593d1634 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:13:33 -0600 Subject: [PATCH 24/26] fix: make form scrollable --- .../frontend/js_src/lib/components/BatchIdentify/index.tsx | 4 ++-- specifyweb/frontend/js_src/lib/localization/batchIdentify.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index e73121efaae..7e9d3bec0e2 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -978,8 +978,8 @@ function BatchIdentifyDialog({ ))}
)} -
-
+
+
Date: Thu, 5 Mar 2026 21:35:28 -0600 Subject: [PATCH 25/26] fix tests --- .../frontend/js_src/lib/components/BatchIdentify/index.tsx | 2 +- .../lib/components/BatchIdentify/parseCatalogNumbers.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 7e9d3bec0e2..6c51ff2197d 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -170,7 +170,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: readonly number[] = []; + const collectionObjectIds: number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts index 544a5b74833..4f7f8e65832 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts @@ -9,7 +9,7 @@ export const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); export const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: readonly CatalogToken[] = []; + const tokens: CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -35,7 +35,7 @@ export const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: readonly (readonly [number, number])[] = []; + const ranges: (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index]; From 1a8b520426b090740c9088722b51268e5cf85f44 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:39:35 +0000 Subject: [PATCH 26/26] Lint code with ESLint and Prettier Triggered by 99d58765f781a7a0c734ba0f01843d0706f63704 on branch refs/heads/issue-7764 --- .../frontend/js_src/lib/components/BatchIdentify/index.tsx | 2 +- .../lib/components/BatchIdentify/parseCatalogNumbers.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx index 6c51ff2197d..7e9d3bec0e2 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/index.tsx @@ -170,7 +170,7 @@ const fetchRecordSetCollectionObjectIds = async ( const limit = 2000; let offset = 0; let totalCount = 0; - const collectionObjectIds: number[] = []; + const collectionObjectIds: readonly number[] = []; do { const { records, totalCount: fetchedTotalCount } = await fetchCollection( diff --git a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts index 4f7f8e65832..544a5b74833 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts +++ b/specifyweb/frontend/js_src/lib/components/BatchIdentify/parseCatalogNumbers.ts @@ -9,7 +9,7 @@ export const parseCatalogNumberEntries = (rawEntries: string): RA => .filter((entry) => entry.length > 0); export const tokenizeCatalogEntry = (entry: string): RA => { - const tokens: CatalogToken[] = []; + const tokens: readonly CatalogToken[] = []; let currentNumber = ''; for (const character of entry) { @@ -35,7 +35,7 @@ export const parseCatalogNumberRanges = ( ): RA => entries.flatMap((entry) => { const tokens = tokenizeCatalogEntry(entry); - const ranges: (readonly [number, number])[] = []; + const ranges: readonly (readonly [number, number])[] = []; let index = 0; while (index < tokens.length) { const token = tokens[index];