Compare commits

...

4 Commits

Author SHA1 Message Date
stumpylog
4c7c88711b Use a custom field to handle this, assuming 3 char currency as the generated field does 2026-04-20 15:45:19 -07:00
stumpylog
407ff70c56 Rejects string prefixed exact monetary queries 2026-04-20 14:13:28 -07:00
Trenton H
a74786c25b Merge branch 'dev' into fix/monetary-exact-filter 2026-04-20 10:23:17 -07:00
stumpylog
314a6671de Fixes exact custom field monetary exact searching 2026-04-18 13:30:53 -07:00
2 changed files with 146 additions and 3 deletions

View File

@@ -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(

View File

@@ -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) #
# ==========================================================#