From a89cd2d5d9f2d38d0eabda281a1264435bfaa836 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:01:27 -0700 Subject: [PATCH] Fix: Exact custom field monetary exact searching (#12592) --- src/documents/filters.py | 42 ++++++- .../tests/test_api_filter_by_custom_fields.py | 107 ++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/documents/filters.py b/src/documents/filters.py index ba1dc11c6..ddc784204 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -6,6 +6,8 @@ import json import logging import operator from contextlib import contextmanager +from decimal import Decimal +from decimal import InvalidOperation from typing import TYPE_CHECKING from typing import Any @@ -291,6 +293,34 @@ class MimeTypeFilter(Filter): return qs +class MonetaryAmountField(serializers.Field): + """ + Accepts either a plain decimal string ("100", "100.00") or a currency-prefixed + string ("USD100.00") and returns the numeric amount as a Decimal. + + Mirrors the logic of the value_monetary_amount generated field: if the value + starts with a non-digit, the first 3 characters are treated as a currency code + (ISO 4217) and stripped before parsing. This preserves backwards compatibility + with saved views that stored a currency-prefixed string as the filter value. + """ + + default_error_messages = {"invalid": "A valid number is required."} + + def to_internal_value(self, data): + if not isinstance(data, str | int | float): + self.fail("invalid") + value = str(data).strip() + if value and not value[0].isdigit() and value[0] != "-": + value = value[3:] # strip 3-char ISO 4217 currency code + try: + return Decimal(value) + except InvalidOperation: + self.fail("invalid") + + def to_representation(self, value): + return str(value) + + class SelectField(serializers.CharField): def __init__(self, custom_field: CustomField) -> None: self._options = custom_field.extra_data["select_options"] @@ -516,9 +546,8 @@ class CustomFieldQueryParser: value_field_name = CustomFieldInstance.get_value_field_name( custom_field.data_type, ) - if ( - custom_field.data_type == CustomField.FieldDataType.MONETARY - and op in self.EXPR_BY_CATEGORY["arithmetic"] + if custom_field.data_type == CustomField.FieldDataType.MONETARY and ( + op in self.EXPR_BY_CATEGORY["arithmetic"] or op in {"exact", "in"} ): value_field_name = "value_monetary_amount" has_field = Q(custom_fields__field=custom_field) @@ -628,6 +657,13 @@ class CustomFieldQueryParser: elif custom_field.data_type == CustomField.FieldDataType.URL: # For URL fields we don't need to be strict about validation (e.g., for istartswith). field = serializers.CharField() + elif custom_field.data_type == CustomField.FieldDataType.MONETARY and ( + op in self.EXPR_BY_CATEGORY["arithmetic"] or op in {"exact", "in"} + ): + # These ops compare against value_monetary_amount (a DecimalField). + # MonetaryAmountField accepts both "100" and "USD100.00" for backwards + # compatibility with saved views that stored currency-prefixed values. + field = MonetaryAmountField() else: # The general case: inferred from the corresponding field in CustomFieldInstance. value_field_name = CustomFieldInstance.get_value_field_name( diff --git a/src/documents/tests/test_api_filter_by_custom_fields.py b/src/documents/tests/test_api_filter_by_custom_fields.py index b28fec777..4388d6ec5 100644 --- a/src/documents/tests/test_api_filter_by_custom_fields.py +++ b/src/documents/tests/test_api_filter_by_custom_fields.py @@ -9,6 +9,8 @@ from rest_framework.test import APITestCase from documents.models import CustomField from documents.models import Document +from documents.models import SavedView +from documents.models import SavedViewFilterRule from documents.serialisers import DocumentSerializer from documents.tests.utils import DirectoriesMixin @@ -453,6 +455,111 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase): ), ) + def test_exact_monetary(self) -> None: + # "exact" should match by numeric amount, ignoring currency code prefix. + self._assert_query_match_predicate( + ["monetary_field", "exact", "100"], + lambda document: ( + "monetary_field" in document + and document["monetary_field"] == "USD100.00" + ), + ) + self._assert_query_match_predicate( + ["monetary_field", "exact", "101"], + lambda document: ( + "monetary_field" in document and document["monetary_field"] == "101.00" + ), + ) + + def test_in_monetary(self) -> None: + # "in" should match by numeric amount, ignoring currency code prefix. + self._assert_query_match_predicate( + ["monetary_field", "in", ["100", "50"]], + lambda document: ( + "monetary_field" in document + and document["monetary_field"] in {"USD100.00", "EUR50.00"} + ), + ) + + def test_exact_monetary_with_currency_prefix(self) -> None: + # Providing a currency-prefixed string like "USD100.00" for an exact monetary + # filter should work for backwards compatibility with saved views. The currency + # code is stripped and the numeric amount is used for comparison. + self._assert_query_match_predicate( + ["monetary_field", "exact", "USD100.00"], + lambda document: ( + "monetary_field" in document + and document["monetary_field"] == "USD100.00" + ), + ) + self._assert_query_match_predicate( + ["monetary_field", "in", ["USD100.00", "EUR50.00"]], + lambda document: ( + "monetary_field" in document + and document["monetary_field"] in {"USD100.00", "EUR50.00"} + ), + ) + self._assert_query_match_predicate( + ["monetary_field", "gt", "USD99.00"], + lambda document: ( + "monetary_field" in document + and document["monetary_field"] is not None + and ( + document["monetary_field"] == "USD100.00" + or document["monetary_field"] == "101.00" + ) + ), + ) + + def test_saved_view_with_currency_prefixed_monetary_filter(self) -> None: + """ + A saved view created before the exact-monetary fix stored currency-prefixed + values like '["monetary_field", "exact", "USD100.00"]' as the filter rule value + (rule_type=42). Those saved views must continue to return correct results. + """ + saved_view = SavedView.objects.create(name="test view", owner=self.user) + SavedViewFilterRule.objects.create( + saved_view=saved_view, + rule_type=42, # FILTER_CUSTOM_FIELDS_QUERY + value=json.dumps(["monetary_field", "exact", "USD100.00"]), + ) + # The frontend translates rule_type=42 to the custom_field_query URL param; + # simulate that here using the stored filter rule value directly. + rule = saved_view.filter_rules.get(rule_type=42) + query_string = quote(rule.value, safe="") + response = self.client.get( + "/api/documents/?" + + "&".join( + ( + f"custom_field_query={query_string}", + "ordering=archive_serial_number", + "page=1", + f"page_size={len(self.documents)}", + "truncate_content=true", + ), + ), + ) + self.assertEqual(response.status_code, 200, msg=str(response.json())) + result_ids = {doc["id"] for doc in response.json()["results"]} + # Should match the single document with monetary_field = "USD100.00" + expected_ids = { + doc.id + for doc in self.documents + if doc.custom_fields.filter( + field__name="monetary_field", + value_monetary="USD100.00", + ).exists() + } + self.assertEqual(result_ids, expected_ids) + + def test_monetary_amount_with_invalid_value(self) -> None: + # A value that has a currency prefix but no valid number after it should fail. + self._assert_validation_error( + json.dumps(["monetary_field", "exact", "USDnotanumber"]), + ["custom_field_query", "2"], + "valid number", + ) + # ==========================================================# # Subset check (document link field only) # # ==========================================================#