diff --git a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py index d215ed9d42f..406104c1e25 100644 --- a/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/backend/workbench/upload/tests/test_upload_results_json.py @@ -4,6 +4,8 @@ import unittest from jsonschema import validate, Draft7Validator # type: ignore +from specifyweb.backend.businessrules.exceptions import BusinessRuleException + from ..upload_result import * from ..upload_results_schema import schema @@ -36,6 +38,114 @@ def testFailedBusinessRule(self, failedBusinessRule: FailedBusinessRule): j = json.dumps(failedBusinessRule.to_json()) self.assertEqual(failedBusinessRule, FailedBusinessRule.from_json(json.loads(j))) + def testBusinessRuleExceptionPayload(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + payload = { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": "catalognumber", + "fieldData": {"catalognumber": "0037481"}, + "parentField": "collection", + "parentData": {"collection": "Collection object (360449)"}, + "conflicting": [3347460], + } + + self.assertEqual( + to_failed_business_rule( + BusinessRuleException( + "Collectionobject must have unique catalognumber in collection", + payload, + ), + info, + ), + FailedBusinessRule( + "Collectionobject must have unique catalognumber in collection", + payload, + info, + ), + ) + + def testBusinessRuleExceptionPayloadSanitization(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + + payload = { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": "catalognumber", + "goodNested": {"a": "b", "n": 1, "ok": True, "null": None}, + "badNested": {"bad": info}, + "goodList": [1, 2, 3], + "badList": [1, info], + } + + failed_business_rule = to_failed_business_rule( + Exception( + "Collectionobject must have unique catalognumber in collection", + payload, + ), + info, + ) + + self.assertEqual( + failed_business_rule.payload, + { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": "catalognumber", + "goodNested": {"a": "b", "n": 1, "ok": True, "null": None}, + "goodList": [1, 2, 3], + }, + ) + + # Ensure sanitized payload always serializes in upload results. + json.dumps(failed_business_rule.to_json()) + + def testWrapperFallbackDoesNotMatchGenericTwoArgException(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + + exception = Exception( + "connection failed", + {"table": "Collectionobject", "reason": "timeout"}, + ) + failed_business_rule = to_failed_business_rule(exception, info) + + self.assertEqual(failed_business_rule.payload, {}) + self.assertEqual(failed_business_rule.message, str(exception)) + + def testBusinessRulePayloadPreservesTopLevelNone(self): + info = ReportInfo( + tableName="Collectionobject", + columns=["catalogNumber"], + treeInfo=None, + ) + + payload = { + "localizationKey": "childFieldNotUnique", + "table": "Collectionobject", + "fieldName": None, + "parentField": "collection", + } + + failed_business_rule = to_failed_business_rule( + Exception("Business rule failed", payload), + info, + ) + + self.assertIn("fieldName", failed_business_rule.payload) + self.assertIsNone(failed_business_rule.payload["fieldName"]) + @given(noMatch=infer) def testNoMatch(self, noMatch: NoMatch): j = json.dumps(noMatch.to_json()) diff --git a/specifyweb/backend/workbench/upload/treerecord.py b/specifyweb/backend/workbench/upload/treerecord.py index ce82b1c3644..c3506057809 100644 --- a/specifyweb/backend/workbench/upload/treerecord.py +++ b/specifyweb/backend/workbench/upload/treerecord.py @@ -47,6 +47,7 @@ FailedBusinessRule, ReportInfo, TreeInfo, + to_failed_business_rule, ) from .uploadable import ( Row, @@ -954,7 +955,7 @@ def _upload( obj = self._do_insert(model, **new_attrs) except (BusinessRuleException, IntegrityError) as e: return UploadResult( - FailedBusinessRule(str(e), {}, info), parent_result, {} + to_failed_business_rule(e, info), parent_result, {} ) result = UploadResult(Uploaded(obj.id, info, []), parent_result, {}) diff --git a/specifyweb/backend/workbench/upload/upload_result.py b/specifyweb/backend/workbench/upload/upload_result.py index ea6e13f0ea5..06cb7208781 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -1,10 +1,21 @@ -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast from typing import Literal from .parsing import WorkBenchParseFailure Failure = Literal["Failure"] +BUSINESS_RULE_EXCEPTION_MODULE = "specifyweb.backend.businessrules.exceptions" +BUSINESS_RULE_EXCEPTION_NAME = "BusinessRuleException" +BusinessRulePayloadValue = ( + str + | int + | bool + | None + | list[str | int | bool | None] + | dict[str, str | int | bool | None] +) +BusinessRulePayload = dict[str, BusinessRulePayloadValue] class TreeInfo(NamedTuple): @@ -215,7 +226,7 @@ def from_json(json: dict) -> "Deleted": class FailedBusinessRule(NamedTuple): message: str - payload: dict[str, str | int | list[str] | list[int]] + payload: BusinessRulePayload info: ReportInfo def get_id(self) -> Failure: @@ -238,6 +249,79 @@ def from_json(json: dict) -> "FailedBusinessRule": ) +def is_business_rule_exception_with_payload(exception: Exception) -> bool: + exception_class = exception.__class__ + payload_like_exception = ( + len(exception.args) >= 2 + and isinstance(exception.args[0], str) + and isinstance(exception.args[1], dict) + ) + + if not payload_like_exception: + return False + + # Some wrapped code paths can preserve a business-rule payload without + # preserving the original exception class identity. + has_business_rule_shape = isinstance( + exception.args[1].get("localizationKey"), str + ) + + return ( + ( + exception_class.__module__ == BUSINESS_RULE_EXCEPTION_MODULE + and exception_class.__name__ == BUSINESS_RULE_EXCEPTION_NAME + ) + or has_business_rule_shape + ) + + +def _is_business_rule_scalar(value: Any) -> bool: + return isinstance(value, (str, int, bool)) or value is None + + +_SANITIZE_FAILED = object() + + +def _sanitize_business_rule_payload_value(value: Any) -> BusinessRulePayloadValue | object: + if _is_business_rule_scalar(value): + return value + + if isinstance(value, list): + if all(_is_business_rule_scalar(item) for item in value): + return value + return _SANITIZE_FAILED + + if isinstance(value, dict): + sanitized: dict[str, str | int | bool | None] = {} + for key, item in value.items(): + if not isinstance(key, str) or not _is_business_rule_scalar(item): + return _SANITIZE_FAILED + sanitized[key] = item + return sanitized + + return _SANITIZE_FAILED + + +def _sanitize_business_rule_payload(payload: dict[Any, Any]) -> BusinessRulePayload: + sanitized: BusinessRulePayload = {} + for key, value in payload.items(): + if not isinstance(key, str): + continue + sanitized_value = _sanitize_business_rule_payload_value(value) + if sanitized_value is _SANITIZE_FAILED: + continue + sanitized[key] = cast(BusinessRulePayloadValue, sanitized_value) + return sanitized + + +def to_failed_business_rule(exception: Exception, info: ReportInfo) -> FailedBusinessRule: + if is_business_rule_exception_with_payload(exception): + payload = _sanitize_business_rule_payload(exception.args[1]) + return FailedBusinessRule(exception.args[0], payload, info) + + return FailedBusinessRule(str(exception), {}, info) + + class NoMatch(NamedTuple): info: ReportInfo diff --git a/specifyweb/backend/workbench/upload/upload_table.py b/specifyweb/backend/workbench/upload/upload_table.py index a5ab4cb3f9a..8b91798a582 100644 --- a/specifyweb/backend/workbench/upload/upload_table.py +++ b/specifyweb/backend/workbench/upload/upload_table.py @@ -39,6 +39,7 @@ PicklistAddition, ParseFailures, PropagatedFailure, + to_failed_business_rule, ) from .uploadable import ( NULL_RECORD, @@ -591,7 +592,7 @@ def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: except ContetRef as e: # Not sure if there is a better way for this. Consider moving this to binding. return UploadResult( - FailedBusinessRule(str(e), {}, info), to_one_results, {} + to_failed_business_rule(e, info), to_one_results, {} ) attrs = { @@ -760,7 +761,7 @@ def _do_upload( picklist_additions = self._do_picklist_additions() except (BusinessRuleException, IntegrityError) as e: return UploadResult( - FailedBusinessRule(str(e), {}, info), to_one_results, {} + to_failed_business_rule(e, info), to_one_results, {} ) record = Uploaded(uploaded.id, info, picklist_additions) @@ -865,7 +866,7 @@ def delete_row(self, parent_obj=None) -> UploadResult: reference_record.delete() result = Deleted(self.current_id, info) except (BusinessRuleException, IntegrityError) as e: - result = FailedBusinessRule(str(e), {}, info) + result = to_failed_business_rule(e, info) to_one_deleted: dict[str, UploadResult] = { key: value.delete_row() @@ -1066,7 +1067,7 @@ def _do_upload( picklist_additions = self._do_picklist_additions() except (BusinessRuleException, IntegrityError) as e: return UploadResult( - FailedBusinessRule(str(e), {}, info), to_one_results, {} + to_failed_business_rule(e, info), to_one_results, {} ) record: Updated | NoChange = ( diff --git a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts index a14c686ace2..b50cce6a84d 100644 --- a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/utils.ts @@ -6,8 +6,8 @@ import { f } from '../../utils/functools'; import type { IR, RA, RR } from '../../utils/types'; import { tables } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; -import { resolveBackendParsingMessage } from '../WorkBench/resultsParser'; import type { LocalityUpdateHeader, LocalityUpdateTaskStatus } from './types'; +import { resolveBackendParsingMessage } from '../WorkBench/resultMessageResolvers'; const localityUpdateAcceptedLocalityFields: RA< Lowercase diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index 805344218c9..281abdcba31 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -2,7 +2,7 @@ import { whitespaceSensitive } from '../../localization/utils'; import { wbText } from '../../localization/workbench'; import { ajax } from '../../utils/ajax'; import type { RA, Writable, WritableArray } from '../../utils/types'; -import { capitalize, mappedFind, toLowerCase } from '../../utils/utils'; +import { capitalize, mappedFind } from '../../utils/utils'; import type { Tables } from '../DataModel/types'; import { raise } from '../Errors/Crash'; import { pathStartsWith } from '../WbPlanView/helpers'; @@ -35,10 +35,7 @@ type Records = WritableArray< >; // Just to make things manageable -type RecordCountsKey = keyof Pick< - UploadResult['UploadResult']['record_result'], - 'Deleted' | 'MatchedAndChanged' | 'Updated' | 'Uploaded' ->; +type RecordCountsKey = 'Deleted' | 'MatchedAndChanged' | 'Updated' | 'Uploaded'; export type RecordCounts = Partial< Record, number>>> @@ -65,6 +62,30 @@ type UploadResults = { readonly interestingRecords: Records; }; +type KeysOfUnion = T extends unknown ? keyof T : never; + +type UploadStatus = Extract< + KeysOfUnion, + string +>; + +const getRecordResultEntry = ( + recordResult: UploadResult['UploadResult']['record_result'] +): readonly [UploadStatus, unknown] | undefined => + Object.entries(recordResult)[0] as [UploadStatus, unknown] | undefined; + +const hasUploadInfo = ( + value: unknown +): value is { + readonly info: { + readonly treeInfo: { + readonly rank: string; + readonly name: string; + } | null; + }; +} => + typeof value === 'object' && value !== null && 'info' in value; + /* eslint-disable functional/no-this-expression */ export class WbValidation { // eslint-disable-next-line functional/prefer-readonly-type @@ -293,7 +314,7 @@ export class WbValidation { } private resolveUploadStatus( - uploadStatus: keyof UploadResult['UploadResult']['record_result'], + uploadStatus: UploadStatus, recordResult: UploadResult['UploadResult']['record_result'], physicalRow: number, mappingPath: MappingPath, @@ -315,7 +336,7 @@ export class WbValidation { uploadStatus ) ) { - } else if (uploadStatus === 'ParseFailures') + } else if ('ParseFailures' in recordResult) recordResult.ParseFailures.failures.forEach((line) => { const [issueMessage, payload, column] = line.length === 2 ? [line[0], {}, line[1]] : line; @@ -328,14 +349,14 @@ export class WbValidation { resolveColumns ); }); - else if (uploadStatus === 'NoMatch') + else if ('NoMatch' in recordResult) setMetaCallback( 'issues', wbText.noMatchErrorMessage(), recordResult.NoMatch.info.columns, resolveColumns ); - else if (uploadStatus === 'FailedBusinessRule') + else if ('FailedBusinessRule' in recordResult) setMetaCallback( 'issues', whitespaceSensitive( @@ -347,7 +368,7 @@ export class WbValidation { recordResult.FailedBusinessRule.info.columns, resolveColumns ); - else if (uploadStatus === 'MatchedMultiple') { + else if ('MatchedMultiple' in recordResult) { this.uploadResults.ambiguousMatches[physicalRow] ??= []; this.uploadResults.ambiguousMatches[physicalRow].push({ physicalCols: this.resolveValidationColumns( @@ -364,7 +385,7 @@ export class WbValidation { recordResult.MatchedMultiple.info.columns, resolveColumns ); - } else if (uploadStatus === 'AttachmentFailure') + } else if ('AttachmentFailure' in recordResult) setMetaCallback( 'issues', whitespaceSensitive( @@ -377,46 +398,47 @@ export class WbValidation { ); // TODO: Discuss if MatchedAndChanged needs to shown. or whatever. else if ( - uploadStatus === 'Uploaded' || - uploadStatus === 'Updated' || - uploadStatus === 'MatchedAndChanged' || - uploadStatus === 'Deleted' + 'Uploaded' in recordResult || + 'Updated' in recordResult || + 'MatchedAndChanged' in recordResult || + 'Deleted' in recordResult ) { - // All these meta ones are interesting - const metaKey = - uploadStatus === 'Uploaded' - ? 'isNew' - : uploadStatus === 'Updated' - ? 'isUpdated' - : uploadStatus === 'MatchedAndChanged' - ? 'isMatchedAndChanged' - : 'isDeleted'; + const [statusKey, statusData, metaKey] = 'Uploaded' in recordResult + ? (['Uploaded', recordResult.Uploaded, 'isNew'] as const) + : 'Updated' in recordResult + ? (['Updated', recordResult.Updated, 'isUpdated'] as const) + : 'MatchedAndChanged' in recordResult + ? (['MatchedAndChanged', recordResult.MatchedAndChanged, 'isMatchedAndChanged'] as const) + : (['Deleted', recordResult.Deleted, 'isDeleted'] as const); + setMetaCallback( metaKey, true, - recordResult[uploadStatus].info.columns, + statusData.info.columns, undefined ); - const tableName = toLowerCase(recordResult[uploadStatus].info.tableName); - this.uploadResults.recordCounts[uploadStatus] ??= {}; - this.uploadResults.recordCounts[uploadStatus]![tableName]! ??= 0; - this.uploadResults.recordCounts[uploadStatus]![tableName]! += 1; + const tableName = statusData.info.tableName.toLowerCase() as Lowercase< + keyof Tables + >; + this.uploadResults.recordCounts[statusKey] ??= {}; + this.uploadResults.recordCounts[statusKey]![tableName]! ??= 0; + this.uploadResults.recordCounts[statusKey]![tableName]! += 1; - if (uploadStatus === 'Deleted') return; // Not sure if there is any value in showing deleted id's itself, right? + if (statusKey === 'Deleted') return; // Not sure if there is any value in showing deleted id's itself, right? const writable = this.uploadResults.interestingRecords; writable[physicalRow] ??= []; this.resolveValidationColumns( - recordResult[uploadStatus].info.columns, + statusData.info.columns, undefined ).forEach((physicalCol) => { writable[physicalRow]![physicalCol] ??= []; writable[physicalRow]![physicalCol].push([ tableName, - recordResult[uploadStatus].id, - recordResult[uploadStatus].info?.treeInfo - ? `${recordResult[uploadStatus].info.treeInfo!.name} (${recordResult[uploadStatus].info.treeInfo!.rank})` + statusData.id, + statusData.info?.treeInfo + ? `${statusData.info.treeInfo!.name} (${statusData.info.treeInfo!.rank})` : '', ]); }); @@ -441,12 +463,16 @@ export class WbValidation { initialMappingPath: MappingPath | undefined = [] ): void { const uploadResult = result.UploadResult; - const uploadStatus = Object.keys(uploadResult.record_result)[0]; - const statusData = uploadResult.record_result[uploadStatus]; + const recordResultEntry = getRecordResultEntry(uploadResult.record_result); + if (recordResultEntry === undefined) return; + + const [uploadStatus, statusData] = recordResultEntry; + + const info = hasUploadInfo(statusData) ? statusData.info : undefined; - const isTree = 'info' in statusData && statusData.info?.treeInfo !== null; + const isTree = info?.treeInfo !== null && info !== undefined; const mappingPath = isTree - ? [...initialMappingPath, formatTreeRank(statusData.info.treeInfo.rank)] + ? [...initialMappingPath, formatTreeRank(info.treeInfo!.rank)] : initialMappingPath; this.resolveUploadStatus( diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts new file mode 100644 index 00000000000..4d3e792d40c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts @@ -0,0 +1,94 @@ +import { backEndText } from '../../../localization/backEnd'; +import { requireContext } from '../../../tests/helpers'; + +import { resolveValidationMessage } from '../resultsParser'; + +requireContext(); + +describe('resolveValidationMessage business-rule handling', () => { + test('formats childFieldNotUnique and appends conflicting record ids', () => { + const message = resolveValidationMessage('notAParsingKey', { + localizationKey: 'childFieldNotUnique', + table: 'Collectionobject', + fieldName: 'catalognumber', + parentField: 'collection', + conflicting: [4, 9], + }); + + const localizedSuffix = backEndText.conflictingRecordIds({ ids: '4, 9' }); + + expect(message).toContain('unique'); + expect(message).toContain(localizedSuffix); + }); + + test('does not append conflicting ids when no valid ids are provided', () => { + const message = resolveValidationMessage('notAParsingKey', { + localizationKey: 'childFieldNotUnique', + table: 'Collectionobject', + fieldName: 'catalognumber', + parentField: 'collection', + conflicting: [{ id: 4 }], + }); + + expect(message).toContain('unique'); + expect(message).not.toContain('Conflicting record IDs:'); + }); + + test('falls back to backend key when business-rule payload has no table', () => { + const payload = { + localizationKey: 'fieldNotUnique', + fieldName: 'catalognumber', + conflicting: [4], + }; + + expect(resolveValidationMessage('unknownKey', payload)).toBe('unknownKey'); + }); + + test('does not stringify unknown business-rule payload internals', () => { + const payload = { + localizationKey: 'notRegisteredBusinessRule', + parentData: { collection: 'Collection object (1)' }, + conflicting: [4], + }; + + expect(resolveValidationMessage('backend raw business-rule text', payload)).toBe( + 'backend raw business-rule text' + ); + }); + + test('resolves datasetAlreadyUploaded via localizationKey payload', () => { + const message = resolveValidationMessage('backend raw business-rule text', { + localizationKey: 'datasetAlreadyUploaded', + }); + + expect(message).toBe(backEndText.datasetAlreadyUploaded()); + }); + + test('resolves non-uniqueness business-rule key with payload arguments', () => { + const message = resolveValidationMessage('backend raw business-rule text', { + localizationKey: 'resourceInPermissionRegistry', + resource: 'my-resource', + }); + + expect(message).toBe( + backEndText.resourceInPermissionRegistry({ + resource: 'my-resource', + }) + ); + }); + + test('parsing message takes precedence over business-rule message', () => { + const message = resolveValidationMessage('failedParsingBoolean', { + localizationKey: 'childFieldNotUnique', + table: 'Collectionobject', + fieldName: 'catalognumber', + parentField: 'collection', + value: 'not-a-bool', + conflicting: [4], + }); + + expect(message).toBe( + backEndText.failedParsingBoolean({ value: 'not-a-bool' }) + ); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts new file mode 100644 index 00000000000..c88dd3c9c34 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts @@ -0,0 +1,328 @@ +import type { LocalizedString } from 'typesafe-i18n'; + +import { backEndText } from '../../localization/backEnd'; +import type { IR, RA, RR } from '../../utils/types'; +import { localized } from '../../utils/types'; +import { + formatConjunction, + formatDisjunction, +} from '../Atoms/Internationalization'; +import { getField } from '../DataModel/helpers'; +import { getTable, tables } from '../DataModel/tables'; + +type PayloadMessageResolver = (payload: IR) => LocalizedString; + +type BusinessRuleMessageResolver = ( + payload: IR +) => LocalizedString | undefined; + +export const backendParsingMessageResolvers: RR = { + failedParsingBoolean: (payload): LocalizedString => + backEndText.failedParsingBoolean({ value: payload.value as string }), + failedParsingDecimal: (payload): LocalizedString => + backEndText.failedParsingDecimal({ value: payload.value as string }), + failedParsingFloat: (payload): LocalizedString => + backEndText.failedParsingFloat({ value: payload.value as string }), + failedParsingAgentType: (payload): LocalizedString => + backEndText.failedParsingAgentType({ + agentTypeField: getField(tables.Agent, 'agentType').label, + badType: payload.badType as string, + validTypes: formatDisjunction( + (payload.validTypes as RA) ?? [] + ), + }), + valueTooLong: (payload): LocalizedString => + backEndText.valueTooLong({ + maxLength: payload.maxLength as number, + }), + invalidYear: (payload): LocalizedString => + backEndText.invalidYear({ + value: payload.value as string, + }), + badDateFormat: (payload): LocalizedString => + backEndText.badDateFormat({ + value: payload.value as string, + format: payload.format as string, + }), + coordinateBadFormat: (payload): LocalizedString => + backEndText.coordinateBadFormat({ + value: payload.value as string, + }), + latitudeOutOfRange: (payload): LocalizedString => + backEndText.latitudeOutOfRange({ + value: payload.value as string, + }), + longitudeOutOfRange: (payload): LocalizedString => + backEndText.longitudeOutOfRange({ + value: payload.value as string, + }), + formatMismatch: (payload): LocalizedString => + backEndText.formatMismatch({ + value: payload.value as string, + formatter: payload.formatter as string, + }), +}; + +export function resolveBackendParsingMessage( + key: string, + payload: IR +): LocalizedString | undefined { + const resolver = backendParsingMessageResolvers[key]; + return resolver?.(payload); +} + +function withConflictingRecordIds( + message: LocalizedString, + payload: IR +): LocalizedString { + const conflicting = payload.conflicting; + const conflictingIds = Array.isArray(conflicting) + ? conflicting + .filter( + (value): value is string | number => + typeof value === 'string' || typeof value === 'number' + ) + .map((value) => String(value)) + : []; + return conflictingIds.length > 0 + ? localized( + `${message} (${backEndText.conflictingRecordIds({ + ids: conflictingIds.join(', '), + })})` + ) + : message; +} + +function getStringPayload(payload: IR, key: string): string { + const value = payload[key]; + return typeof value === 'string' ? value : ''; +} + +function getObjectPayload(payload: IR, key: string): IR { + const value = payload[key]; + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as IR) + : {}; +} + +function getNestedStringPayload( + payload: IR, + key: string, + nestedKey: string +): string { + return getStringPayload(getObjectPayload(payload, key), nestedKey); +} + +function getSchemaTableLabel(tableName: string): LocalizedString { + return getTable(tableName)?.label ?? localized(tableName); +} + +function getSchemaFieldLabel( + tableName: string, + fieldName: string +): LocalizedString { + const lookupFieldName = fieldName.split('__').join('.'); + return ( + getTable(tableName)?.getField(lookupFieldName)?.label ?? + localized(fieldName) + ); +} + +function getSchemaFieldLabels( + tableName: string, + fieldNames: string +): LocalizedString { + const labels = fieldNames + .split(',') + .map((fieldName) => fieldName.trim()) + .filter((fieldName) => fieldName.length > 0) + .map((fieldName) => getSchemaFieldLabel(tableName, fieldName)); + return labels.length === 0 + ? localized(fieldNames) + : formatConjunction(labels); +} + +export const businessRuleMessageResolvers: RR = { + fieldNotUnique: (payload): LocalizedString | undefined => { + const tableName = getStringPayload(payload, 'table'); + const fieldName = getStringPayload(payload, 'fieldName'); + if (tableName.length === 0 || fieldName.length === 0) return undefined; + return withConflictingRecordIds( + backEndText.fieldNotUnique({ + tableName: getSchemaTableLabel(tableName), + fieldName: getSchemaFieldLabels(tableName, fieldName), + }), + payload + ); + }, + childFieldNotUnique: (payload): LocalizedString | undefined => { + const tableName = getStringPayload(payload, 'table'); + const fieldName = getStringPayload(payload, 'fieldName'); + const parentField = getStringPayload(payload, 'parentField'); + if ( + tableName.length === 0 || + fieldName.length === 0 || + parentField.length === 0 + ) + return undefined; + return withConflictingRecordIds( + backEndText.childFieldNotUnique({ + tableName: getSchemaTableLabel(tableName), + fieldName: getSchemaFieldLabels(tableName, fieldName), + parentField: getSchemaFieldLabels(tableName, parentField), + }), + payload + ); + }, + badTreeStructureInvalidRanks: (payload): LocalizedString => + backEndText.badTreeStructureInvalidRanks({ + badRanks: Number(payload.badRanks) || 0, + }), + deletingTreeRoot: (): LocalizedString => backEndText.deletingTreeRoot(), + nodeParentInvalidRank: (): LocalizedString => backEndText.nodeParentInvalidRank(), + nodeChildrenInvalidRank: (): LocalizedString => + backEndText.nodeChildrenInvalidRank(), + nodeOperationToSynonymizedParent: (payload): LocalizedString => + backEndText.nodeOperationToSynonymizedParent({ + operation: getStringPayload(payload, 'operation'), + nodeName: getNestedStringPayload(payload, 'node', 'fullName'), + parentName: getNestedStringPayload(payload, 'parent', 'fullName'), + }), + nodeSynonymizeToSynonymized: (payload): LocalizedString => + backEndText.nodeSynonymizeToSynonymized({ + nodeName: getNestedStringPayload(payload, 'node', 'fullName'), + intoName: getNestedStringPayload(payload, 'synonymized', 'fullName'), + }), + nodeSynonimizeWithChildren: (payload): LocalizedString => + backEndText.nodeSynonimizeWithChildren({ + nodeName: getNestedStringPayload(payload, 'parent', 'fullName'), + }), + invalidNodeType: (payload): LocalizedString => + backEndText.invalidNodeType({ + node: `${payload.node ?? ''}`, + operation: getStringPayload(payload, 'operation'), + nodeModel: getStringPayload(payload, 'nodeModel'), + }), + operationAcrossTrees: (payload): LocalizedString => + backEndText.operationAcrossTrees({ + operation: getStringPayload(payload, 'operation'), + }), + limitReachedDeterminingAccepted: (payload): LocalizedString => + backEndText.limitReachedDeterminingAccepted({ + taxonId: Number(payload.taxonId) || 0, + }), + resourceInPermissionRegistry: (payload): LocalizedString => + backEndText.resourceInPermissionRegistry({ + resource: getStringPayload(payload, 'resource'), + }), + actorIsNotSpecifyUser: (payload): LocalizedString => + backEndText.actorIsNotSpecifyUser({ + agentTable: tables.Agent.label, + specifyUserTable: tables.SpecifyUser.label, + actor: getStringPayload(payload, 'actor'), + }), + unexpectedCollectionType: (payload): LocalizedString => + backEndText.unexpectedCollectionType({ + unexpectedTypeName: getStringPayload(payload, 'unexpectedTypeName'), + collectionName: getStringPayload(payload, 'collectionName'), + }), + invalidReportMimetype: (): LocalizedString => + backEndText.invalidReportMimetype({ + mimeTypeField: getField(tables.SpAppResource, 'mimeType').label, + }), + fieldNotRelationship: (payload): LocalizedString => + backEndText.fieldNotRelationship({ + field: getStringPayload(payload, 'field'), + }), + unexpectedTableId: (payload): LocalizedString => + backEndText.unexpectedTableId({ + tableId: `${payload.tableId ?? ''}`, + expectedTableId: `${payload.expectedTableId ?? ''}`, + }), + noCollectionInQuery: (payload): LocalizedString => + backEndText.noCollectionInQuery({ + table: getStringPayload(payload, 'table'), + }), + invalidDatePart: (payload): LocalizedString => + backEndText.invalidDatePart({ + datePart: getStringPayload(payload, 'datePart'), + validDateParts: getStringPayload(payload, 'validDateParts'), + }), + invalidUploadStatus: (payload): LocalizedString => + backEndText.invalidUploadStatus({ + uploadStatus: `${payload.uploadStatus ?? ''}`, + operation: getStringPayload(payload, 'operation'), + expectedUploadStatus: getStringPayload(payload, 'expectedUploadStatus'), + }), + datasetAlreadyUploaded: (): LocalizedString => + backEndText.datasetAlreadyUploaded(), +}; + +export function resolveBackendBusinessRuleMessage( + key: string, + payload: IR +): LocalizedString | undefined { + const localizationKey = getStringPayload(payload, 'localizationKey') || key; + if (localizationKey.length === 0) return undefined; + + const resolver = businessRuleMessageResolvers[localizationKey]; + return resolver?.(payload); +} + +export const validationMessageResolvers: RR = { + failedParsingPickList: (payload): LocalizedString => + backEndText.failedParsingPickList({ + value: `"${payload.value as string}"`, + }), + pickListValueTooLong: (payload): LocalizedString => + backEndText.pickListValueTooLong({ + pickListTable: tables.PickList.label, + pickList: payload.pickList as string, + maxLength: payload.maxLength as number, + }), + invalidPartialRecord: (payload): LocalizedString => + backEndText.invalidPartialRecord({ + column: payload.column as string, + }), + fieldRequiredByUploadPlan: (): LocalizedString => + backEndText.fieldRequiredByUploadPlan(), + invalidTreeStructure: (): LocalizedString => backEndText.invalidTreeStructure(), + scopeChangeError: (): LocalizedString => backEndText.scopeChangeDetected(), + multipleTreeDefsInRow: (): LocalizedString => + backEndText.multipleTreeDefsInRow(), + invalidCotype: (): LocalizedString => backEndText.invalidCotype(), + invalidComponentType: (): LocalizedString => + backEndText.invalidComponentType({ + componentType: tables.Component.field.type.label, + }), + missingRequiredTreeParent: (payload): LocalizedString => + backEndText.missingRequiredTreeParent({ + names: formatConjunction((payload.names as RA) ?? []), + }), +}; + +export function resolveSpecificValidationMessage( + key: string, + payload: IR +): LocalizedString | undefined { + const resolver = validationMessageResolvers[key]; + return resolver?.(payload); +} + +export const attachmentValidationMessageResolvers: RR< + string, + () => LocalizedString +> = { + attachmentNotFound: (): LocalizedString => backEndText.attachmentNotFound(), + tableDoesNotSupportAttachments: (): LocalizedString => + backEndText.tableDoesNotSupportAttachments(), + attachmentAlreadyLinked: (): LocalizedString => + backEndText.attachmentAlreadyLinked(), +}; + +export function resolveAttachmentValidationMessageByKey( + key: string +): LocalizedString { + return attachmentValidationMessageResolvers[key]?.() ?? + backEndText.attachmentNotFound(); +} diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index 76d187c9ba2..fd222fa67e7 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -5,314 +5,47 @@ */ import type { LocalizedString } from 'typesafe-i18n'; -import type { State } from 'typesafe-reducer'; -import { backEndText } from '../../localization/backEnd'; -import type { IR, RA, RR } from '../../utils/types'; +import type { IR } from '../../utils/types'; import { localized } from '../../utils/types'; import { - formatConjunction, - formatDisjunction, -} from '../Atoms/Internationalization'; -import { getField } from '../DataModel/helpers'; -import { tables } from '../DataModel/tables'; -import type { Tables } from '../DataModel/types'; - -/* - * If an UploadResult involves a tree record, this metadata indicates - * where in the tree the record resides - */ -type TreeInfo = { - // The tree rank a record relates to - readonly rank: string; - // The name of the tree node a record relates to - readonly name: string; -}; - -/* - * Records metadata about an UploadResult indicating the tables, data set - * columns, and any tree information involved - */ -type ReportInfo = { - // The name of the table a record relates to - readonly tableName: keyof Tables; - // The columns from the data set a record relates to - readonly columns: RA; - readonly treeInfo: TreeInfo | null; -}; - -/* - * Indicates that a value had to be added to a picklist during uploading - * a record - */ -type PicklistAddition = { - // The new picklistitem id - readonly id: number; - // The name of the picklist receiving the new item - readonly name: string; - // The value of the new item - readonly value: string; - // The data set column that produced the new item - readonly caption: string; -}; - -// Indicates that a new row was added to the database -type Uploaded = State< - 'Uploaded', - { - // The database id of the added row - readonly id: number; - readonly picklistAdditions: RA; - readonly info: ReportInfo; - } ->; - -// Indicates that an existing record in the database was matched -type Matched = State< - 'Matched', - { - // The id of the matched database row - readonly id: number; - readonly info: ReportInfo; - } ->; - -// Indicates failure due to finding multiple matches to existing records -type MatchedMultiple = State< - 'MatchedMultiple', - { - // List of ids of the matching database records - readonly ids: RA; - readonly key: string; - readonly info: ReportInfo; - } ->; - -/* - * Indicates that no record was uploaded because all relevant columns in - * the data set are empty - */ -type NullRecord = State< - 'NullRecord', - { - readonly info: ReportInfo; - } ->; - -// Indicates a record didn't upload due to a business rule violation -type FailedBusinessRule = State< - 'FailedBusinessRule', - { - // The error message generated by the business rule exception - readonly message: string; - readonly payload?: IR; - readonly info: ReportInfo; - } ->; - -// Indicates failure due to an error associated with a row's attachments -type AttachmentFailure = State< - 'AttachmentFailure', - { - readonly message: string; - readonly info: ReportInfo; - } ->; - -/* - * Indicates failure due to inability to find an expected existing - * matching record - */ -type NoMatch = State< - 'NoMatch', - { - readonly info: ReportInfo; - } ->; - -/* - * Indicates one or more values were invalid, preventing a record - * from uploading - */ -type ParseFailures = State< - 'ParseFailures', - { - readonly failures: RA< - readonly [string, IR, string] | readonly [string, string] - >; - } ->; - -type Updated = State<'Updated', Omit>; - -type NoChange = State< - 'NoChange', - { - readonly id: number; - readonly info: ReportInfo; - } ->; - -type Deleted = State< - 'Deleted', - { readonly id: number; readonly info: ReportInfo } ->; -// Indicates failure due to a failure to upload a related record -type PropagatedFailure = State<'PropagatedFailure'>; - -type MatchedAndChanged = State<'MatchedAndChanged', Omit>; - -type RecordResultTypes = - | AttachmentFailure - | Deleted - | Deleted - | FailedBusinessRule - | Matched - | MatchedAndChanged - | MatchedAndChanged - | MatchedMultiple - | NoChange - | NoChange - | NoMatch - | NullRecord - | ParseFailures - | PropagatedFailure - | Updated - | Uploaded; - -// Records the specific result of attempting to upload a particular record -type WbRecordResult = { - readonly [recordResultType in RecordResultTypes['type']]: Omit< - Extract>, - 'type' - >; -}; - -export type UploadResult = { - readonly UploadResult: { - readonly record_result: WbRecordResult; - /* - * Maps the names of -to-one relationships of the table to upload - * results for each - * 'parent' exists for tree nodes only - */ - readonly toOne: RR; - /* - * Maps the names of -to-many relationships of the table to an - * array of upload results for each - */ - readonly toMany: IR>; - }; -}; - -export function resolveBackendParsingMessage( - key: string, - payload: IR -): LocalizedString | undefined { - if (key === 'failedParsingBoolean') - return backEndText.failedParsingBoolean({ value: payload.value as string }); - else if (key === 'failedParsingDecimal') - return backEndText.failedParsingDecimal({ value: payload.value as string }); - else if (key === 'failedParsingFloat') - return backEndText.failedParsingFloat({ value: payload.value as string }); - else if (key === 'failedParsingAgentType') - return backEndText.failedParsingAgentType({ - agentTypeField: getField(tables.Agent, 'agentType').label, - badType: payload.badType as string, - validTypes: formatDisjunction( - (payload.validTypes as RA) ?? [] - ), - }); - else if (key === 'valueTooLong') - return backEndText.valueTooLong({ - maxLength: payload.maxLength as number, - }); - else if (key === 'invalidYear') - return backEndText.invalidYear({ - value: payload.value as string, - }); - else if (key === 'badDateFormat') - return backEndText.badDateFormat({ - value: payload.value as string, - format: payload.format as string, - }); - else if (key === 'coordinateBadFormat') - return backEndText.coordinateBadFormat({ - value: payload.value as string, - }); - else if (key === 'latitudeOutOfRange') - return backEndText.latitudeOutOfRange({ - value: payload.value as string, - }); - else if (key === 'longitudeOutOfRange') - return backEndText.longitudeOutOfRange({ - value: payload.value as string, - }); - else if (key === 'formatMismatch') - return backEndText.formatMismatch({ - value: payload.value as string, - formatter: payload.formatter as string, - }); - else return undefined; -} + resolveAttachmentValidationMessageByKey, + resolveBackendBusinessRuleMessage, + resolveBackendParsingMessage, + resolveSpecificValidationMessage, +} from './resultMessageResolvers'; +export type { UploadResult } from './uploadResultTypes'; /** Back-end sends a validation key. Front-end translates it */ export function resolveValidationMessage( key: string, payload: IR ): LocalizedString { + const isBusinessRule = typeof payload.localizationKey === 'string'; const baseParsedMessage = resolveBackendParsingMessage(key, payload); + const businessRuleMessage = resolveBackendBusinessRuleMessage(key, payload); if (baseParsedMessage !== undefined) { return baseParsedMessage; - } else if (key === 'failedParsingPickList') - return backEndText.failedParsingPickList({ - value: `"${payload.value as string}"`, - }); - else if (key === 'pickListValueTooLong') - return backEndText.pickListValueTooLong({ - pickListTable: tables.PickList.label, - pickList: payload.pickList as string, - maxLength: payload.maxLength as number, - }); - else if (key === 'invalidPartialRecord') - return backEndText.invalidPartialRecord({ - column: payload.column as string, - }); - else if (key === 'fieldRequiredByUploadPlan') - return backEndText.fieldRequiredByUploadPlan(); - else if (key === 'invalidTreeStructure') - return backEndText.invalidTreeStructure(); - else if (key === 'scopeChangeError') return backEndText.scopeChangeDetected(); - else if (key === 'multipleTreeDefsInRow') - return backEndText.multipleTreeDefsInRow(); - else if (key === 'invalidCotype') return backEndText.invalidCotype(); - else if (key === 'invalidComponentType') - return backEndText.invalidComponentType({ - componentType: tables.Component.field.type.label, - }); - else if (key === 'missingRequiredTreeParent') - return backEndText.missingRequiredTreeParent({ - names: formatConjunction((payload.names as RA) ?? []), - }); + } else if (businessRuleMessage !== undefined) { + return businessRuleMessage; + } + + const specificValidationMessage = resolveSpecificValidationMessage(key, payload); + if (specificValidationMessage !== undefined) { + return specificValidationMessage; + } + if (isBusinessRule) return localized(key); + // This can happen for data sets created before 7.8.2 - else - return localized( - `${key}${ - Object.keys(payload).length === 0 ? '' : ` ${JSON.stringify(payload)}` - }` - ); + return localized( + `${key}${ + Object.keys(payload).length === 0 ? '' : ` ${JSON.stringify(payload)}` + }` + ); } export function resolveAttachmentValidationMessage( key: string ): LocalizedString { - if (key === 'attachmentNotFound') { - return backEndText.attachmentNotFound(); - } else if (key === 'tableDoesNotSupportAttachments') { - return backEndText.tableDoesNotSupportAttachments(); - } else if (key === 'attachmentAlreadyLinked') { - return backEndText.attachmentAlreadyLinked(); - } else { - return backEndText.attachmentNotFound(); - } + return resolveAttachmentValidationMessageByKey(key); } diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts new file mode 100644 index 00000000000..096446c8280 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/uploadResultTypes.ts @@ -0,0 +1,193 @@ +import type { State } from 'typesafe-reducer'; + +import type { IR, RA, RR } from '../../utils/types'; +import type { Tables } from '../DataModel/types'; + +/* + * If an UploadResult involves a tree record, this metadata indicates + * where in the tree the record resides + */ +type TreeInfo = { + // The tree rank a record relates to + readonly rank: string; + // The name of the tree node a record relates to + readonly name: string; +}; + +/* + * Records metadata about an UploadResult indicating the tables, data set + * columns, and any tree information involved + */ +type ReportInfo = { + // The name of the table a record relates to + readonly tableName: keyof Tables; + // The columns from the data set a record relates to + readonly columns: RA; + readonly treeInfo: TreeInfo | null; +}; + +/* + * Indicates that a value had to be added to a picklist during uploading + * a record + */ +type PicklistAddition = { + // The new picklistitem id + readonly id: number; + // The name of the picklist receiving the new item + readonly name: string; + // The value of the new item + readonly value: string; + // The data set column that produced the new item + readonly caption: string; +}; + +// Indicates that a new row was added to the database +type Uploaded = State< + 'Uploaded', + { + // The database id of the added row + readonly id: number; + readonly picklistAdditions: RA; + readonly info: ReportInfo; + } +>; + +// Indicates that an existing record in the database was matched +type Matched = State< + 'Matched', + { + // The id of the matched database row + readonly id: number; + readonly info: ReportInfo; + } +>; + +// Indicates failure due to finding multiple matches to existing records +type MatchedMultiple = State< + 'MatchedMultiple', + { + // List of ids of the matching database records + readonly ids: RA; + readonly key: string; + readonly info: ReportInfo; + } +>; + +/* + * Indicates that no record was uploaded because all relevant columns in + * the data set are empty + */ +type NullRecord = State< + 'NullRecord', + { + readonly info: ReportInfo; + } +>; + +// Indicates a record didn't upload due to a business rule violation +type FailedBusinessRule = State< + 'FailedBusinessRule', + { + // The error message generated by the business rule exception + readonly message: string; + readonly payload?: IR; + readonly info: ReportInfo; + } +>; + +// Indicates failure due to an error associated with a row's attachments +type AttachmentFailure = State< + 'AttachmentFailure', + { + readonly message: string; + readonly info: ReportInfo; + } +>; + +/* + * Indicates failure due to inability to find an expected existing + * matching record + */ +type NoMatch = State< + 'NoMatch', + { + readonly info: ReportInfo; + } +>; + +/* + * Indicates one or more values were invalid, preventing a record + * from uploading + */ +type ParseFailures = State< + 'ParseFailures', + { + readonly failures: RA< + readonly [string, IR, string] | readonly [string, string] + >; + } +>; + +type Updated = State<'Updated', Omit>; + +type NoChange = State< + 'NoChange', + { + readonly id: number; + readonly info: ReportInfo; + } +>; + +type Deleted = State< + 'Deleted', + { readonly id: number; readonly info: ReportInfo } +>; +// Indicates failure due to a failure to upload a related record +type PropagatedFailure = State<'PropagatedFailure'>; + +type MatchedAndChanged = State<'MatchedAndChanged', Omit>; + +type RecordResultTypes = + | AttachmentFailure + | Deleted + | Deleted + | FailedBusinessRule + | Matched + | MatchedAndChanged + | MatchedAndChanged + | MatchedMultiple + | NoChange + | NoChange + | NoMatch + | NullRecord + | ParseFailures + | PropagatedFailure + | Updated + | Uploaded; + +// Records the specific result of attempting to upload a particular record +type WbRecordResult = { + readonly [recordResultType in RecordResultTypes['type']]: { + readonly [key in recordResultType]: Omit< + Extract>, + 'type' + >; + }; +}[RecordResultTypes['type']]; + +export type UploadResult = { + readonly UploadResult: { + readonly record_result: WbRecordResult; + /* + * Maps the names of -to-one relationships of the table to upload + * results for each + * 'parent' exists for tree nodes only + */ + readonly toOne: RR; + /* + * Maps the names of -to-many relationships of the table to an + * array of upload results for each + */ + readonly toMany: IR>; + }; +}; diff --git a/specifyweb/frontend/js_src/lib/localization/backEnd.ts b/specifyweb/frontend/js_src/lib/localization/backEnd.ts index b340ca7c140..76fc1085f5a 100644 --- a/specifyweb/frontend/js_src/lib/localization/backEnd.ts +++ b/specifyweb/frontend/js_src/lib/localization/backEnd.ts @@ -321,6 +321,9 @@ export const backEndText = createDictionary({ '{tableName:string} mora imati jedinstveni {fieldName:string} u {parentField:string}', nb: '{tableName:string} må ha unik {fieldName:string} i {parentField:string}', }, + conflictingRecordIds: { + 'en-us': 'Conflicting record IDs: {ids:string}', + }, deletingTreeRoot: { 'en-us': 'Can not delete root level tree definition item', 'es-es':