Skip to content

Preserve Workbench business rule payloads for clearer validation errors#8048

Open
acwhite211 wants to merge 9 commits into
mainfrom
issue-8045
Open

Preserve Workbench business rule payloads for clearer validation errors#8048
acwhite211 wants to merge 9 commits into
mainfrom
issue-8045

Conversation

@acwhite211
Copy link
Copy Markdown
Member

@acwhite211 acwhite211 commented Apr 30, 2026

Fixes #8045

Improve Workbench handling of back-end business rule validation errors. The back-end now preserves structured BusinessRuleException payloads in FailedBusinessRule upload results instead of stringifying and discarding them. The Workbench front-end uses those payloads to show uniqueness-rule failures and appends the conflicting record IDs when available. This makes duplicate catalog number errors clearer and avoids exposing raw Python exception tuples in validation tooltips.

Checklist

  • Self-review the PR after opening it to make sure the changes look good and
    self-explanatory (or properly documented)
  • Add relevant issue to release milestone
  • Add pr to documentation list
  • Add automated tests

Testing instructions

  • Open a Workbench dataset that triggers a duplicate catalog number uniqueness error.
  • Run validation.
  • Hover or inspect the errored cell.
  • Confirm the message reads like a normal validation error, for example Collectionobject must have unique catalognumber in collection.
  • Confirm the message includes conflicting record IDs when provided, for example Conflicting record IDs: 3347460.
  • Confirm the tooltip no longer shows the raw Python tuple/dict payload.

Summary by CodeRabbit

  • Bug Fixes

    • Validation error messages now display localized field labels and conflicting record identifiers for uniqueness constraint violations.
  • Improvements

    • Enhanced handling of data validation exceptions with improved error message formatting and localization support.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

📝 Walkthrough

Walkthrough

This PR preserves business rule exception payloads through Workbench upload flows and renders them as localized, user-friendly validation messages. Previously, exceptions were stringified and lost structured details; now they flow from backend exception handlers through conversion helpers to frontend rendering with schema-aware labels.

Changes

Business Rule Exception Payload Preservation

Layer / File(s) Summary
Payload structure and conversion helpers
specifyweb/backend/workbench/upload/upload_result.py
Defines constants identifying BusinessRuleException by module/name, type aliases for payload shape (BusinessRulePayloadValue, BusinessRulePayload), and conversion functions. is_business_rule_exception_with_payload(exception) validates exception structure; to_failed_business_rule(exception, info) converts matching exceptions into FailedBusinessRule with preserved payload, falling back to stringified exception with empty payload otherwise. Updates FailedBusinessRule.payload field type.
Payload conversion unit test
specifyweb/backend/workbench/upload/tests/test_upload_results_json.py
Tests that BusinessRuleException with message and payload args converts via to_failed_business_rule into FailedBusinessRule with matching message, payload structure, and report info.
Backend upload paths using payload conversion
specifyweb/backend/workbench/upload/treerecord.py, specifyweb/backend/workbench/upload/upload_table.py
Applies to_failed_business_rule conversion in three upload exception handlers: tree-record insert/clone, main table upload flow, row delete, and bound update save. Replaces inline FailedBusinessRule(str(e), {}, info) construction with helper call, preserving structured payloads.
Frontend payload localization and display
specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts
Imports getTable to resolve schema labels. Introduces message-building helpers to extract payload fields, generate localized table/field names, format field conjunctions, append conflicting record IDs, and translate fieldNotUnique and childFieldNotUnique rules into readable messages. Updates resolveValidationMessage to prefer parsing translations and fall back to localized business-rule messages when parsing translation unavailable.
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Testing Instructions ⚠️ Warning Instructions cover only one end-to-end scenario. Missing guidance on unit tests, tree-record/delete paths, edge cases, and localization verification from review comments. Add instructions for unit tests, other upload paths (tree-record, delete), edge case testing, frontend testing, and localization/serialization verification from review feedback.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: preserving structured BusinessRuleException payloads in validation errors instead of stringifying them.
Linked Issues check ✅ Passed The PR directly addresses issue #8045 by implementing the required functionality: preserving structured payloads, rendering localized validation messages, and displaying conflicting record IDs.
Out of Scope Changes check ✅ Passed All changes are focused on the stated objective: backend payload preservation, helper functions for conversion, test coverage, and frontend localization of validation messages.
Automatic Tests ✅ Passed PR includes automatic tests: testBusinessRuleExceptionPayload was added to test the new to_failed_business_rule helper with a real business rule exception payload, verifying payload preservation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-8045

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@acwhite211 acwhite211 added this to the 7.12.2 milestone Apr 30, 2026
@acwhite211 acwhite211 marked this pull request as ready for review May 18, 2026 21:47
@acwhite211 acwhite211 requested review from a team May 18, 2026 21:47
Copy link
Copy Markdown
Collaborator

@emenslin emenslin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Confirm the message reads like a normal validation error, for example Collectionobject must have unique catalognumber in collection.
  • Confirm the message includes conflicting record IDs when provided, for example Conflicting record IDs: 3347460.
  • Confirm the tooltip no longer shows the raw Python tuple/dict payload.

Although this looks a lot better I do have one change I think is needed. I think that the error should show either the schema caption set by the user (e.g. CIDA Number instead of catalognumber) or the schema config field name (e.g. catalogNumber instead of catalognumber)

@github-project-automation github-project-automation Bot moved this from 📋Back Log to Dev Attention Needed in General Tester Board May 19, 2026
Comment thread specifyweb/backend/workbench/upload/upload_result.py Fixed
@acwhite211 acwhite211 requested a review from emenslin May 21, 2026 19:19
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
specifyweb/backend/workbench/upload/tests/test_upload_results_json.py (1)

41-70: ⚡ Quick win

Extend this test to cover JSON round-trip of preserved payload.

This asserts conversion correctness, but not serialization/schema compatibility for the preserved payload. Add a small UploadResult(...).to_json() -> json.dumps/json.loads -> from_json assertion in the same test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/backend/workbench/upload/tests/test_upload_results_json.py` around
lines 41 - 70, Extend the testBusinessRuleExceptionPayload to also verify JSON
round-trip: create the FailedBusinessRule via
to_failed_business_rule(BusinessRuleException(...), info), wrap it in an
UploadResult (use the same FailedBusinessRule instance), call
UploadResult.to_json() then json.loads and pass that into
UploadResult.from_json() (or the project equivalent) and assert the deserialized
UploadResult still contains the original FailedBusinessRule payload (compare the
preserved payload dict and message). Ensure you use the existing symbols:
BusinessRuleException, ReportInfo, to_failed_business_rule, FailedBusinessRule,
UploadResult.to_json and UploadResult.from_json for locating the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specifyweb/backend/workbench/upload/upload_result.py`:
- Around line 253-261: The helper is_business_rule_exception_with_payload
currently accepts any dict as the payload (exception.args[1]) which can contain
non-serializable or invalid values; update the logic in
is_business_rule_exception_with_payload (and the similar check around lines
264-268) to validate and/or sanitize the payload shape before treating it as a
business-rule payload: ensure exception.args[1] is a dict whose keys are strings
and whose values are only JSON-serializable scalar types (str, int, float, bool,
None) or plain lists/dicts that recursively satisfy the same constraint, or else
reject it (or replace with a safe fallback like an empty dict or
{"unserializable": true}) so that constructing/encoding FailedBusinessRule will
not fail at runtime. Include references to
is_business_rule_exception_with_payload and the code path that constructs
FailedBusinessRule when applying this validation.

In `@specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts`:
- Around line 305-336: In resolveBackendBusinessRuleMessage, guard against
missing/empty table by checking the result of getStringPayload(payload, 'table')
(assigned to tableName) and return undefined immediately if it's falsy; this
prevents calling getSchemaTableLabel('') and producing empty localized table
labels. Keep the existing branches that use tableName (fieldNotUnique,
childFieldNotUnique) but only execute them when tableName is non-empty so
callers can fall back to the default message.
- Around line 259-269: The message suffix is hardcoded in English inside
withConflictingRecordIds, causing mixed locales; update withConflictingRecordIds
to fetch a localized prefix from backEndText (e.g., add a conflictingRecordIds
key to the backEndText dictionary) and use that localized string (via
localized/backEndText lookup) instead of the hardcoded "Conflicting record IDs:"
before joining payload.conflicting; ensure IR payload handling and existing
localized(message) wrapping remain unchanged so the full tooltip is entirely
localized.

---

Nitpick comments:
In `@specifyweb/backend/workbench/upload/tests/test_upload_results_json.py`:
- Around line 41-70: Extend the testBusinessRuleExceptionPayload to also verify
JSON round-trip: create the FailedBusinessRule via
to_failed_business_rule(BusinessRuleException(...), info), wrap it in an
UploadResult (use the same FailedBusinessRule instance), call
UploadResult.to_json() then json.loads and pass that into
UploadResult.from_json() (or the project equivalent) and assert the deserialized
UploadResult still contains the original FailedBusinessRule payload (compare the
preserved payload dict and message). Ensure you use the existing symbols:
BusinessRuleException, ReportInfo, to_failed_business_rule, FailedBusinessRule,
UploadResult.to_json and UploadResult.from_json for locating the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f43e892-a787-4b63-a34a-af5fa0eda045

📥 Commits

Reviewing files that changed from the base of the PR and between 66b1266 and 3007e56.

📒 Files selected for processing (5)
  • specifyweb/backend/workbench/upload/tests/test_upload_results_json.py
  • specifyweb/backend/workbench/upload/treerecord.py
  • specifyweb/backend/workbench/upload/upload_result.py
  • specifyweb/backend/workbench/upload/upload_table.py
  • specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts

Comment on lines +253 to +261
def is_business_rule_exception_with_payload(exception: Exception) -> bool:
exception_class = exception.__class__
return (
exception_class.__module__ == BUSINESS_RULE_EXCEPTION_MODULE
and exception_class.__name__ == BUSINESS_RULE_EXCEPTION_NAME
and len(exception.args) >= 2
and isinstance(exception.args[0], str)
and isinstance(exception.args[1], dict)
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate/sanitize preserved payload values before returning FailedBusinessRule.

Right now any dict in exception.args[1] is accepted. If a business-rule payload includes non-serializable values, upload-result JSON encoding can fail at runtime. Please gate payload contents to the allowed shape (or safely fallback).

Proposed fix
+def _is_payload_value(value: Any) -> bool:
+    if isinstance(value, (str, int, bool)) or value is None:
+        return True
+    if isinstance(value, list):
+        return all(isinstance(v, (str, int)) for v in value)
+    if isinstance(value, dict):
+        return all(
+            isinstance(k, str) and (isinstance(v, (str, int, bool)) or v is None)
+            for k, v in value.items()
+        )
+    return False
+
+def _is_business_rule_payload(payload: dict[Any, Any]) -> bool:
+    return all(isinstance(k, str) and _is_payload_value(v) for k, v in payload.items())
+
 def is_business_rule_exception_with_payload(exception: Exception) -> bool:
     exception_class = exception.__class__
     return (
         exception_class.__module__ == BUSINESS_RULE_EXCEPTION_MODULE
         and exception_class.__name__ == BUSINESS_RULE_EXCEPTION_NAME
         and len(exception.args) >= 2
         and isinstance(exception.args[0], str)
         and isinstance(exception.args[1], dict)
+        and _is_business_rule_payload(exception.args[1])
     )

Also applies to: 264-268

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/backend/workbench/upload/upload_result.py` around lines 253 - 261,
The helper is_business_rule_exception_with_payload currently accepts any dict as
the payload (exception.args[1]) which can contain non-serializable or invalid
values; update the logic in is_business_rule_exception_with_payload (and the
similar check around lines 264-268) to validate and/or sanitize the payload
shape before treating it as a business-rule payload: ensure exception.args[1] is
a dict whose keys are strings and whose values are only JSON-serializable scalar
types (str, int, float, bool, None) or plain lists/dicts that recursively
satisfy the same constraint, or else reject it (or replace with a safe fallback
like an empty dict or {"unserializable": true}) so that constructing/encoding
FailedBusinessRule will not fail at runtime. Include references to
is_business_rule_exception_with_payload and the code path that constructs
FailedBusinessRule when applying this validation.

Comment on lines +259 to +269
function withConflictingRecordIds(
message: LocalizedString,
payload: IR<unknown>
): LocalizedString {
const conflicting = payload.conflicting;
return Array.isArray(conflicting) && conflicting.length > 0
? localized(
`${message} (Conflicting record IDs: ${conflicting.join(', ')})`
)
: message;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hardcoded English string defeats localization.

The "Conflicting record IDs:" prefix is hardcoded in English while the surrounding message is localized via backEndText. Non-English users will see a mixed-locale tooltip, which is exactly the kind of UX issue this PR is trying to clean up. Please route this string through backEndText (e.g., a new conflictingRecordIds entry) so it gets translated alongside the rule message.

🌐 Suggested shape
-    ? localized(
-        `${message} (Conflicting record IDs: ${conflicting.join(', ')})`
-      )
+    ? localized(
+        `${message} (${backEndText.conflictingRecordIds({
+          ids: conflicting.join(', '),
+        })})`
+      )
     : message;

(Add a corresponding conflictingRecordIds key to the backEndText localization dictionary.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts` around
lines 259 - 269, The message suffix is hardcoded in English inside
withConflictingRecordIds, causing mixed locales; update withConflictingRecordIds
to fetch a localized prefix from backEndText (e.g., add a conflictingRecordIds
key to the backEndText dictionary) and use that localized string (via
localized/backEndText lookup) instead of the hardcoded "Conflicting record IDs:"
before joining payload.conflicting; ensure IR payload handling and existing
localized(message) wrapping remain unchanged so the full tooltip is entirely
localized.

Comment on lines +305 to +336
function resolveBackendBusinessRuleMessage(
payload: IR<unknown>
): LocalizedString | undefined {
const tableName = getStringPayload(payload, 'table');
if (payload.localizationKey === 'fieldNotUnique')
return withConflictingRecordIds(
backEndText.fieldNotUnique({
tableName: getSchemaTableLabel(tableName),
fieldName: getSchemaFieldLabels(
tableName,
getStringPayload(payload, 'fieldName')
),
}),
payload
);
else if (payload.localizationKey === 'childFieldNotUnique')
return withConflictingRecordIds(
backEndText.childFieldNotUnique({
tableName: getSchemaTableLabel(tableName),
fieldName: getSchemaFieldLabels(
tableName,
getStringPayload(payload, 'fieldName')
),
parentField: getSchemaFieldLabels(
tableName,
getStringPayload(payload, 'parentField')
),
}),
payload
);
else return undefined;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against missing table in payload.

If payload.table is absent or non-string, getStringPayload returns '', and getSchemaTableLabel('') falls back to localized('') — producing a message like "must have unique catalognumber in collection" with an empty table label. Consider returning undefined from resolveBackendBusinessRuleMessage when tableName is empty so the caller falls through to a saner default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts` around
lines 305 - 336, In resolveBackendBusinessRuleMessage, guard against
missing/empty table by checking the result of getStringPayload(payload, 'table')
(assigned to tableName) and return undefined immediately if it's falsy; this
prevents calling getSchemaTableLabel('') and producing empty localized table
labels. Keep the existing branches that use tableName (fieldNotUnique,
childFieldNotUnique) but only execute them when tableName is non-empty so
callers can fall back to the default message.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Dev Attention Needed

Development

Successfully merging this pull request may close these issues.

Workbench shows raw business rule exception for duplicate catalog number validation

3 participants