From 8bf89e58ce5754b0f413c5d61d5e4cfd7fca4f0d Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:16:39 -0500 Subject: [PATCH 1/5] Implement to_failed_business_rule helper function --- .../backend/workbench/upload/upload_result.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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 From d6dffa2c95f001ff804a6f23e2836d2a4900a7be Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:17:13 -0500 Subject: [PATCH 2/5] Replace FailedBusinessRule with to_failed_business_rule in treerecord.py --- specifyweb/backend/workbench/upload/treerecord.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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, {}) From adfbba1e8e3a352170d01d10c189113f7c00495a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:17:38 -0500 Subject: [PATCH 3/5] Replace FailedBusinessRule with to_failed_business_rule in upload_table.py --- specifyweb/backend/workbench/upload/upload_table.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 = ( From b5a50dc9f3c09aa8a1f1244f9d79d2b076c527cf Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:19:32 -0500 Subject: [PATCH 4/5] Add unit test testBusinessRuleExceptionPayload --- .../upload/tests/test_upload_results_json.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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()) From 9d8e306c2b6c6ba422244a647fde212da02ff8ee Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 30 Apr 2026 12:20:19 -0500 Subject: [PATCH 5/5] Fix BusinessRuleMessage issue --- .../lib/components/WorkBench/resultsParser.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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}"`,