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..c4d3776b147 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,37 @@ 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, + ), + ) + @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..2b4e305f53b 100644 --- a/specifyweb/backend/workbench/upload/upload_result.py +++ b/specifyweb/backend/workbench/upload/upload_result.py @@ -2,9 +2,21 @@ from typing import Literal +from specifyweb.backend.businessrules.exceptions import BusinessRuleException + from .parsing import WorkBenchParseFailure Failure = Literal["Failure"] +BusinessRulePayloadValue = ( + str + | int + | bool + | None + | list[str] + | list[int] + | dict[str, str | int | bool | None] +) +BusinessRulePayload = dict[str, BusinessRulePayloadValue] class TreeInfo(NamedTuple): @@ -215,7 +227,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 +250,18 @@ def from_json(json: dict) -> "FailedBusinessRule": ) +def to_failed_business_rule(exception: Exception, info: ReportInfo) -> FailedBusinessRule: + if ( + isinstance(exception, BusinessRuleException) + and len(exception.args) >= 2 + and isinstance(exception.args[0], str) + and isinstance(exception.args[1], dict) + ): + return FailedBusinessRule(exception.args[0], exception.args[1], 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..15689e2be34 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, @@ -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/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index 76d187c9ba2..11fa104dde4 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -256,14 +256,57 @@ export function resolveBackendParsingMessage( else return undefined; } +function withConflictingRecordIds( + message: LocalizedString, + payload: IR +): LocalizedString { + const conflicting = payload.conflicting; + return Array.isArray(conflicting) && conflicting.length > 0 + ? localized( + `${message} (Conflicting record IDs: ${conflicting.join(', ')})` + ) + : message; +} + +function getStringPayload(payload: IR, key: string): string { + const value = payload[key]; + return typeof value === 'string' ? value : ''; +} + +function resolveBackendBusinessRuleMessage( + payload: IR +): LocalizedString | undefined { + if (payload.localizationKey === 'fieldNotUnique') + return withConflictingRecordIds( + backEndText.fieldNotUnique({ + tableName: getStringPayload(payload, 'table'), + fieldName: getStringPayload(payload, 'fieldName'), + }), + payload + ); + else if (payload.localizationKey === 'childFieldNotUnique') + return withConflictingRecordIds( + backEndText.childFieldNotUnique({ + tableName: getStringPayload(payload, 'table'), + fieldName: getStringPayload(payload, 'fieldName'), + parentField: getStringPayload(payload, 'parentField'), + }), + payload + ); + else return undefined; +} + /** Back-end sends a validation key. Front-end translates it */ export function resolveValidationMessage( key: string, payload: IR ): LocalizedString { const baseParsedMessage = resolveBackendParsingMessage(key, payload); + const businessRuleMessage = resolveBackendBusinessRuleMessage(payload); if (baseParsedMessage !== undefined) { return baseParsedMessage; + } else if (businessRuleMessage !== undefined) { + return businessRuleMessage; } else if (key === 'failedParsingPickList') return backEndText.failedParsingPickList({ value: `"${payload.value as string}"`,